Elizium.Loopz.psm1

using module Elizium.Klassy;
using module Elizium.Krayola;
Set-StrictMode -Version 1.0

function Rename-Many {
  <#
  .NAME
    Rename-Many
 
  .SYNOPSIS
    Performs a bulk rename for all file system objects delivered through the pipeline,
  via regular expression replacement.
 
  .DESCRIPTION
    The user should assemble the candidate items from the file system, be they files or
  directories typically using Get-ChildItem, or can be any other function that delivers
  file systems items via the PowerShell pipeline. For each item in the pipeline,
  Rename-Many will perform a rename.
    Rename-Many is a powerful command and should be used with caution. Because of the
  potential of accidental misuse, a number of protections have been put in place:
 
  * By default, the command is locked. This means that the command will not actually
  perform any renames until it has been unlocked by the user. When locked, the command
  runs as though -WhatIf has been specified. There are indications in the output to show
  that the command is in a locked state (there is an indicator in the batch header and
  a 'Novice' indicator in the summary). To activate the command, the user needs to
  set the environment variable 'LOOPZ_REMY_LOCKED' to $false. The user should not
  unlock the command until they are comfortable with how to use this command properly
  and knows how to write regular expressions correctly. (See regex101.com)
 
  * An undo script is generated by default. If the user has invoked a rename operation
  by accident without specifying $WhatIf (or any other WhatIf equivalent like $Diagnose)
  then the user can execute the undo script to reverse the rename operation. The user
  should clearly do this immediately on recognising the error of their ways. In a panic,
  the user may terminate the command via ctrl-c. In this case, a partial undo script is
  still generated and should contain the undo operations for the renames that were
  performed up to the point of the termination request.
    The name of the undo script is based upon the current date and time and is displayed
  in the summary. (The user can, if they wish disable the undo feature if they don't want
  to have to manage the accumulation of undo scripts, by setting the environment variable
  LOOPZ_REMY_UNDO_DISABLED to $true)
 
  Another important point of note is that there are currently 3 modes of operation:
  'move', 'update' or 'cut':
  * 'move': requires an anchor, which may be an $Anchor pattern or
    either $Start or $End switches.
  * 'update': requires $With or $Paste without an anchor.
  * 'cut': no anchor or $With/$Paste specified, the $Pattern match is simply removed
    from the name.
 
  The following regular expression parameters:
  * $Pattern
  * $Anchor
  * $Copy
  can optionally have an occurrence value specified that can be used to select which
  match is active. In the case where a provided expression has multiple matches, the
  occurrence value can be used to single out which one. When no occurrence is specified,
  the default is the first match only. The occurrence for a parameter can be:
 
  * f: first occurrence
  * l: last occurrence
  * <number>: the nth occurrence
  The occurrence is specified after the regular expression eg:
  -Pattern '\w\d{2,3}', l
    which means match the Last occurrence of the expression.
  (Actually, an occurrence may be specified for $Include and $Except but there is no
  point in doing so because these patterns only provide a filtering function and play
  no part in the actual renaming process).
 
    A note about escaping. If a pattern needs to use an regular expression character as
  a literal, it must be escaped. There are multiple ways of doing this:
  * use the 'esc' function; eg: -Pattern $($esc('(\d{2})'))
  * use a leading ~; -Pattern '~(123)'
 
  The above 2 approaches escape the entire string. The second approach is more concise
  and avoids the necessary use of extra brackets and $.
  * use 'esc' alongside other string concatenation:
    eg: -Pattern $($esc('(123)') + '-(?<ccy>GBP|SEK)').
  This third method is required when the whole pattern should not be subjected to
  escaping.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Anchor
    Indicates that the rename operation will be a move of the token from its original point
  to the point indicated by Anchor. Anchor is a regular expression string applied to the
  pipeline item's name (after the $Pattern match has been removed). The $Pattern match that
  is removed is inserted at the position indicated by the anchor match in collaboration with
  the $Relation parameter.
 
  .PARAMETER AnchorEnd
    Similar to Anchor except that if the pattern specified by AnchorEnd does not match, then
  the Pattern match will be moved to the End. This is known as a Hybrid Anchor.
 
  .PARAMETER AnchorStart
    Similar to Anchor except that if the pattern specified by AnchorEnd does not match, then
  the Pattern match will be moved to the Start. This is known as a Hybrid Anchor.
 
  .PARAMETER Append
    Appends a literal string to end of items name
 
  .PARAMETER Condition
    Provides another way of filtering pipeline items. This is not typically specified on the
  command line, rather it is meant for those wanting to build functionality on top of Rename-Many.
 
  .PARAMETER Context
    Provides another way of customising Rename-Many. This is not typically specified on the
  command line, rather it is meant for those wanting to build functionality on top of Rename-Many.
  $Context should be a PSCustomObject with the following note properties:
  * Title (default: 'Rename') the name used in the batch header.
  * ItemMessage (default: 'Rename Item') the operation name used for each renamed item.
  * SummaryMessage (default: 'Rename Summary') the name used in the batch summary.
  * Locked (default: 'LOOPZ_REMY_LOCKED) the name of the environment variable which controls
    the locking of the command.
  * DisabledEnVar (default: 'LOOPZ_REMY_UNDO_DISABLED') the name of the environment variable
    which controls if the undo script feature is disabled.
  * UndoDisabledEnVar (default: 'LOOPZ_REMY_UNDO_DISABLED') the name of the environment
    variable which determines if the Undo feature is disabled. This allows any other function
    built on top of Rename-Many to control the undo feature for itself independently of
    Rename-Many.
 
  .PARAMETER Copy
    Regular expression string applied to the pipeline item's name (after the $Pattern match
  has been removed), indicating a portion which should be copied and re-inserted (via the
  $Paste parameter; see $Paste or $With). Since this is a regular expression to be used in
  $Paste/$With, there is no value in the user specifying a static pattern, because that
  static string can just be defined in $Paste/$With. The value in the $Copy parameter comes
  when a generic pattern is defined eg \d{3} (is non Literal), specifies any 3 digits as
  opposed to say '123', which could be used directly in the $Paste/$With parameter without
  the need for $Copy. The match defined by $Copy is stored in special variable ${_c} and
  can be referenced as such from $Paste and $With.
 
  .PARAMETER Cut
    Is a replacement for the Pattern parameter, when a Cut operation is required. The matched
  items will be removed from the item's name, and no other replacement occurs.
 
  .PARAMETER Diagnose
    switch parameter that indicates the command should be run in WhatIf mode. When enabled
  it presents additional information that assists the user in correcting the un-expected
  results caused by an incorrect/un-intended regular expression. The current diagnosis
  will show the contents of named capture groups that they may have specified. When an item
  is not renamed (usually because of an incorrect regular expression), the user can use the
  diagnostics along side the 'Not Renamed' reason to track down errors. When $Diagnose has
  been specified, $WhatIf does not need to be specified.
 
  .PARAMETER Directory
    switch to indicate only Directory items in the pipeline will be processed. If neither
  this switch or the File switch are specified, then both File and Directory items
  are processed.
 
  .PARAMETER Drop
    A string parameter (only applicable to move operations, ie Anchor/Star/End/hybrid) that
  defines what text is used to replace the Pattern match. So in this use-case, the user wants
  to move a particular token/pattern to another part of the name and at the same time drop a
  static string in the place where the $Pattern was removed from.
 
  .PARAMETER End
    Is another type of anchor used instead of $Anchor and specifies that the $Pattern match
  should be moved to the end of the new name.
 
  .PARAMETER Except
    Regular expression string applied to the original pipeline item's name (before the $Pattern
  match has been removed). Allows the user to exclude some items that have been fed in via the
  pipeline. Those items that match the exclusion are skipped during the rename batch.
 
  .PARAMETER File
    switch to indicate only File items in the pipeline will be processed. If neither
  this switch or the Directory switch are specified, then both File and Directory items
  are processed.
 
  .PARAMETER Include
    Regular expression string applied to the original pipeline item's name (before the $Pattern
  match has been removed). Allows the user to include some items that have been fed in via the
  pipeline. Only those items that match $Include pattern are included during the rename batch,
  the others are skipped. The value of the Include parameter comes when you want to define
  a pattern which pipeline items match, without it be removed from the original name, which is
  what happens with $Pattern. Eg, the user may want to specify the only items that should be
  considered a candidate to be renamed are those that match a particular pattern but doing so
  in $Pattern would simply remove that pattern. That may be ok, but if it's not, the user should
  specify a pattern in the $Include and use $Pattern for the match you do want to be moved
  (with Anchor/Start/End) or replaced (with $With/$Paste).
 
  .PARAMETER Paste
    Formatter parameter for Update operations. Can contain named/numbered group references
  defined inside regular expression parameters, or use special named references $0 for the whole
  Pattern match and ${_c} for the whole Copy match.
 
  .PARAMETER Pattern
    Regular expression string that indicates which part of the pipeline items' name that
  either needs to be moved or replaced as part of bulk rename operation. Those characters
  in the name which match are removed from the name.
    The pattern can be followed by an occurrence indicator. As the $Pattern parameter is
  strictly speaking an array, the user can specify the occurrence after the regular
  expression eg:
    $Pattern '(?<code>\w\d{2})', l
 
    => This indicates that the last match should be captured into named group 'code'.
 
  .PARAMETER Prepend
    Prefixes a literal string to start of items name
 
  .PARAMETER Relation
    Used in conjunction with the $Anchor parameter and can be set to either 'before' or
  'after' (the default). Defines the relationship of the $pattern match with the $Anchor
  match in the new name for the pipeline item.
 
  .PARAMETER Start
    Is another type of anchor used instead of $Anchor and specifies that the $Pattern match
  should be moved to the start of the new name.
 
  .PARAMETER Top
    A number indicating how many items to process. If it is known that the number of items
  that will be candidates to be renamed is large, the user can limit this to the first $Top
  number of items. This is typically used as an exploratory tool, to determine the effects
  of the rename operation.
 
  .PARAMETER Transform
    A script block which is given the chance to perform a modification to the finally named
  item. The transform is invoked prior to post-processing, so that the post-processing rules
  are not breached and the transform does not have to worry about breaking them. The transform
  function's signature is as follows:
 
  * Original: original item's name
  * Renamed: new name
  * CapturedPattern: pattern capture
 
  and should return the new name. If the transform does not change the name, it should return
  an empty string.
 
  .PARAMETER Whole
    Provides an alternative way to indicate that the regular expression parameters
  should be treated as a whole word (it just wraps the expression inside \b tokens).
  If set to '*', then it applies to all expression parameters otherwise a single letter
  can specify which of the parameters 'Whole' should be applied to. Valid values are:
 
  * 'p': $Pattern
  * 'a': $Anchor/AnchorEnd/AnchorStart
  * 'c': $Copy
  * 'i': $Include
  * 'x': $Except
  * '*': All the above
  (NB: Currently, can't be set to more than 1 of the above items at a time)
 
  .PARAMETER With
    This is a NON regular expression string. It would be more accurately described as a formatter,
  similar to the $Paste parameter. Defines what text is used as the replacement for the $Pattern
  match. Works in concert with $Relation (whereas $Paste does not). $With can reference special
  variables:
 
  * $0: the pattern match
  * ${_a}: the anchor match
  * ${_c}: the copy match
number of item processed
  When $Pattern contains named capture groups, these variables can also be referenced. Eg if the
  $Pattern is defined as '(?<day>\d{1,2})-(?<mon>\d{1,2})-(?<year>\d{4})', then the variables
  ${day}, ${mon} and ${year} also become available for use in $With or $Paste.
  Typically, $With is literal text which is used to replace the $Pattern match and is inserted
  according to the Anchor match, (or indeed $Start or $End) and $Relation. When using $With,
  whatever is defined in the $Anchor match is removed from the pipeline's name.
 
  .PARAMETER underscore
    The pipeline item which should either be an instance of FileInfo or DirectoryInfo.
 
  .INPUTS
    FileSystemInfo (FileInfo or DirectoryInfo) bound to $underscore
 
  * MOVE EXAMPLES (anchored)
 
  .EXAMPLE 1
  Move a static string before anchor (consider file items only):
 
  gci ... | Rename-Many -File -Pattern 'data' -Anchor 'loopz' -Relation 'before'
 
  .EXAMPLE 2
  Move last occurrence of whole-word static string before anchor:
 
  gci ... | Rename-Many -Pattern 'data',l -Anchor 'loopz' -Relation 'before' -Whole p
 
  .EXAMPLE 3
  Move a static string before anchor and drop (consider Directory items only):
 
  gci ... | Rename-Many -Directory -Pattern 'data' -Anchor 'loopz' -Relation 'before' -Drop '-'
 
  .EXAMPLE 4
  Move a static string before anchor and drop (consider Directory items only), if anchor
  does not match, move the pattern match to end:
   
  gci ... | Rename-Many -Directory -Pattern 'data' -AnchorEnd 'loopz' -Relation 'before' -Drop '-'
 
  .EXAMPLE 5
  Move a static string to start and drop (consider Directory items only):
 
  gci ... | Rename-Many -Directory -Pattern 'data' -Start -Drop '-'
 
  .EXAMPLE 6
  Move a match before anchor:
 
  gci ... | Rename-Many -Pattern '\d{2}-data' -Anchor 'loopz' -Relation 'before'
 
  .EXAMPLE 7
  Move last occurrence of whole-word static string before anchor:
 
  gci ... | Rename-Many -Pattern '\d{2}-data',l -Anchor 'loopz' -Relation 'before' -Whole p
 
  .EXAMPLE 8
  Move a match before anchor and drop:
 
  gci ... | Rename-Many -Pattern '\d{2}-data' -Anchor 'loopz' -Relation 'before' -Drop '-'
 
  * UPDATE EXAMPLES (Paste)
 
  .EXAMPLE 9
  Update last occurrence of whole-word static string using $Paste:
 
  gci ... | Rename-Many -Pattern 'data',l -Whole p -Paste '_info_'
 
  .EXAMPLE 10
  Update a static string using $Paste:
 
  gci ... | Rename-Many -Pattern 'data' -Paste '_info_'
 
  .EXAMPLE 11
  Update 2nd occurrence of whole-word match using $Paste and preserve anchor:
 
  gci ... | Rename-Many -Pattern '\d{2}-data', l -Paste '${_a}_info_'
 
  .EXAMPLE 12
  Update match contain named capture group using $Paste and preserve the anchor:
 
  gci ... | Rename-Many -Pattern (?<day>\d{2})-(?<mon>\d{2})-(?<year>\d{2})
    -Paste '(${year})-(${mon})-(${day}) ${_a}'
 
  .EXAMPLE 13
  Update match contain named capture group using $Paste and preserve the anchor and copy
  whole last occurrence:
 
  gci ... | Rename-Many -Pattern (?<day>\d{2})-(?<mon>\d{2})-(?<year>\d{2})
    -Copy '[A-Z]{3}',l -Whole c -Paste 'CCY_${_c} (${year})-(${mon})-(${day}) ${_a}'
 
  * CUT EXAMPLES (Cut)
 
  .EXAMPLE 14
  Cut a literal token:
 
  gci ... | Rename-Many -Cut 'data'
 
  .EXAMPLE 15
  Cut last occurrence of literal token:
 
  gci ... | Rename-Many -Cut, l 'data'
 
  .EXAMPLE 16
  Cut the second 2 digit sequence:
 
  gci ... | Rename-Many -Cut, 2 '\d{2}'
 
  * APPENDAGE EXAMPLES
 
  .EXAMPLE 17
  Prefix items with fixed token:
 
  gci ... | Rename-Many -Prepend 'begin_'
 
  .EXAMPLE 18
  Append fixed token to items:
 
  gci ... | Rename-Many -Append '_end'
 
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '',
    Justification = 'WhatIf IS accessed and passed into Exchange')]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
  [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'UpdateInPlace')]
  [Alias('remy')]
  param
  (
    [Parameter(Mandatory, ValueFromPipeline = $true)]
    [System.IO.FileSystemInfo]$underscore,

    [Parameter(ParameterSetName = 'HybridStart', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'HybridEnd', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'MoveToAnchor', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'UpdateInPlace', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'MoveToStart', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'MoveToEnd', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'Transformer', Mandatory, Position = 1)]
    [ValidateScript( { { $(test-ValidPatternArrayParam -Arg $_ -AllowWildCard ) } })]
    [Alias('p')]
    [array]$Pattern,

    [Parameter(ParameterSetName = 'MoveToAnchor', Mandatory, Position = 2)]
    [ValidateScript( { $(test-ValidPatternArrayParam -Arg $_) })]
    [Alias('a')]
    [array]$Anchor,

    [Parameter(ParameterSetName = 'HybridStart', Mandatory, Position = 2)]
    [ValidateScript( { $(test-ValidPatternArrayParam -Arg $_) })]
    [Alias('as')]
    [array]$AnchorStart,

    [Parameter(ParameterSetName = 'HybridEnd', Mandatory, Position = 2)]
    [ValidateScript( { $(test-ValidPatternArrayParam -Arg $_) })]
    [Alias('ae')]
    [array]$AnchorEnd,

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [ValidateSet('before', 'after')]
    [Alias('r')]
    [string]$Relation = 'after',

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [Parameter(ParameterSetName = 'UpdateInPlace')]
    [Parameter(ParameterSetName = 'Prefix')]
    [Parameter(ParameterSetName = 'Affix')]
    [Parameter(ParameterSetName = 'MoveToStart')]
    [Parameter(ParameterSetName = 'MoveToEnd')]
    [ValidateScript( { { $(test-ValidPatternArrayParam -Arg $_) } })]
    [Alias('co')]
    [array]$Copy,

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor', Position = 3)]
    [Parameter(ParameterSetName = 'MoveToStart', Position = 2)]
    [Parameter(ParameterSetName = 'MoveToEnd', Position = 2)]
    [Parameter(ParameterSetName = 'UpdateInPlace', Position = 2)]
    [Alias('w')]
    [string]$With,

    [Parameter(ParameterSetName = 'MoveToStart', Mandatory)]
    [Alias('s')]
    [switch]$Start,

    [Parameter(ParameterSetName = 'MoveToEnd', Mandatory)]
    [Alias('e')]
    [switch]$End,

    [Parameter(ParameterSetName = 'UpdateInPlace', Mandatory)]
    [Alias('ps')]
    [string]$Paste,

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [Parameter(ParameterSetName = 'MoveToStart')]
    [Parameter(ParameterSetName = 'MoveToEnd')]
    [Alias('dr')]
    [string]$Drop,

    [Parameter(ParameterSetName = 'NoReplacement', Mandatory)]
    [array]$Cut,

    [Parameter(ParameterSetName = 'Prefix', Mandatory)]
    [Alias('pr')]
    [string]$Prepend,

    [Parameter(ParameterSetName = 'Affix', Mandatory)]
    [Alias('ap')]
    [string]$Append,

    # Defining parameter sets for File and Directory, just to ensure both of these switches
    # are mutually exclusive makes the whole parameter set definition exponentially more
    # complex. It's easier just to enforce this with a ValidateScript.
    #
    [Parameter()]
    [Alias('f')]
    [ValidateScript( { -not($PSBoundParameters.ContainsKey('Directory')); })]
    [switch]$File,

    [Parameter()]
    [Alias('d')]
    [ValidateScript( { -not($PSBoundParameters.ContainsKey('File')); })]
    [switch]$Directory,

    [Parameter()]
    [Alias('x')]
    [string]$Except = [string]::Empty,

    [Parameter()]
    [Alias('i')]
    [string]$Include,

    [Parameter()]
    [ValidateSet('p', 'a', 'c', 'i', 'x', 'u', '*')]
    [Alias('wh')]
    [string]$Whole,

    [Parameter()]
    [scriptblock]$Condition = ( { return $true; }),

    [Parameter()]
    [Alias('t')]
    [ValidateScript( { $_ -gt 0 } )]
    [int]$Top,

    [Parameter(ParameterSetName = 'Transformer', Mandatory)]
    [scriptblock]$Transform,

    [Parameter()]
    [PSCustomObject]$Context = $Loopz.Defaults.Remy.Context,

    [Parameter()]
    [Alias('dg')]
    [switch]$Diagnose,

    [Parameter()]
    [switch]$Test
  )

  begin {
    Write-Debug ">>> Rename-Many [ParameterSet: '$($PSCmdlet.ParameterSetName)]' >>>";

    function get-fixedIndent {
      [OutputType([int])]
      param(
        [Parameter()]
        [hashtable]$Theme,

        [Parameter()]
        [string]$Message = [string]::Empty
      )
      [int]$indent = $Message.Length;

      # 1 2 3 4
      # 1234567890123456789012345678901234567890
      # [🏷️] Rename Item // ["No" => " 1",
      # |<-- fixed bit -->|
      #
      $indent += $Theme['MESSAGE-SUFFIX'].Length;
      $indent += $Theme['OPEN'].Length;
      $indent += $Theme['FORMAT'].Replace($Theme['KEY-PLACE-HOLDER'], "No").Replace(
        $Theme['VALUE-PLACE-HOLDER'], '999').Length;
      $indent += $Theme['SEPARATOR'].Length;
      return $indent;
    }

    function use-actionParams {
      [OutputType([hashtable])]
      param(
        [Parameter(Mandatory)]
        [hashtable]$exchange,

        [Parameter(Mandatory)]
        $endAdapter
      )
      [string]$action = $_exchange['LOOPZ.REMY.ACTION'];
      [boolean]$diagnose = ($_exchange.ContainsKey('LOOPZ.DIAGNOSE') -and
        $_exchange['LOOPZ.DIAGNOSE']);
      [string]$adjustedName = $endAdapter.GetAdjustedName();

      # To do, make the action applicable in all modes
      # We need extra actions:
      # + Cut (handled by move-match)
      # + Add-Appendage append/prepend
      #
      [hashtable]$actionParameters = if ($action -eq 'Add-Appendage') {
        # Bind append/prepend action parameters
        #
        [hashtable]$_params = @{
          'Value'     = $adjustedName;
          'Appendage' = $exchange['LOOPZ.REMY.APPENDAGE'];
          'Type'      = $exchange['LOOPZ.REMY.APPENDAGE.TYPE'];
        }

        $_params;
      }
      else {
        # Bind move/update/(cut/transform) action parameters
        #
        [hashtable]$_params = @{
          'Value' = $adjustedName;
        }

        # Pattern is present for all actions except Cut
        #
        if ($exchange.ContainsKey('LOOPZ.REMY.PATTERN-REGEX')) {
          $_params['Pattern'] = $exchange['LOOPZ.REMY.PATTERN-REGEX'];

          $_params['PatternOccurrence'] = $exchange.ContainsKey('LOOPZ.REMY.PATTERN-OCC') `
            ? $exchange['LOOPZ.REMY.PATTERN-OCC'] : 'f';
        }
        elseif ($exchange.ContainsKey('LOOPZ.REMY.CUT-REGEX')) {
          $_params['Cut'] = $exchange['LOOPZ.REMY.CUT-REGEX'];

          $_params['CutOccurrence'] = $exchange.ContainsKey('LOOPZ.REMY.CUT-OCC') `
            ? $exchange['LOOPZ.REMY.CUT-OCC'] : 'f';
        }

        if ($action -eq 'Move-Match') {
          if ($exchange.ContainsKey('LOOPZ.REMY.ANCHOR.REGEX')) {
            $_params['Anchor'] = $exchange['LOOPZ.REMY.ANCHOR.REGEX'];
          }
          if ($exchange.ContainsKey('LOOPZ.REMY.ANCHOR-OCC')) {
            $_params['AnchorOccurrence'] = $exchange['LOOPZ.REMY.ANCHOR-OCC'];
          }

          if ($exchange.ContainsKey('LOOPZ.REMY.DROP')) {
            $_params['Drop'] = $exchange['LOOPZ.REMY.DROP'];
            $_params['Marker'] = $exchange['LOOPZ.REMY.MARKER'];
          }

          switch ($exchange['LOOPZ.REMY.ANCHOR-TYPE']) {
            'MATCHED-ITEM' {
              if ($exchange.ContainsKey('LOOPZ.REMY.RELATION')) {
                $_params['Relation'] = $exchange['LOOPZ.REMY.RELATION'];
              }
              break;
            }
            'HYBRID-START' {
              if ($exchange.ContainsKey('LOOPZ.REMY.RELATION')) {
                $_params['Relation'] = $exchange['LOOPZ.REMY.RELATION'];
              }
              $_params['Start'] = $true;
              break;
            }
            'HYBRID-END' {
              if ($exchange.ContainsKey('LOOPZ.REMY.RELATION')) {
                $_params['Relation'] = $exchange['LOOPZ.REMY.RELATION'];
              }
              $_params['End'] = $true;
              break;
            }
            'START' {
              $_params['Start'] = $true;
              break;
            }
            'END' {
              $_params['End'] = $true;
              break;
            }
            'CUT' {
              # no op
              break;
            }
            default {
              throw "doRenameFsItems: encountered Invalid 'LOOPZ.REMY.ANCHOR-TYPE': '$AnchorType'";
            }
          }
        } # $action

        $_params;
      }

      # Bind generic action parameters
      #
      if ($diagnose) {
        $actionParameters['Diagnose'] = $exchange['LOOPZ.DIAGNOSE'];
      }

      if ($exchange.ContainsKey('LOOPZ.REMY.COPY.REGEX')) {
        $actionParameters['Copy'] = $exchange['LOOPZ.REMY.COPY.REGEX'];

        if ($exchange.ContainsKey('LOOPZ.REMY.COPY-OCC')) {
          $actionParameters['CopyOccurrence'] = $exchange['LOOPZ.REMY.COPY-OCC'];
        }
      }

      if ($exchange.ContainsKey('LOOPZ.REMY.WITH')) {
        $actionParameters['With'] = $exchange['LOOPZ.REMY.WITH'];
      }

      if ($exchange.ContainsKey('LOOPZ.REMY.PASTE')) {
        $actionParameters['Paste'] = $exchange['LOOPZ.REMY.PASTE'];
      }

      return $actionParameters;
    } # use-actionParams

    function invoke-HandleError {
      param(
        [Parameter()]
        [string]$message,
        
        [Parameter()]
        [string]$prefix,

        [Parameter()]
        [string]$reThrowIfMatch = $pesterAssertionFailure
      )

      [string]$errorReason = $(
        "$prefix`: " + 
        ($message -split '\n')[0]
      );
      # We need Pester to throw pester specific errors. In the lack of, we have to
      # guess that its a Pester assertion failure and let the exception through so
      # the test fails.
      #
      if ($errorReason -match $reThrowIfMatch) {
        throw $_;
      }

      return $errorReason;
    }

    [scriptblock]$doRenameFsItems = {
      [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
      param(
        [Parameter(Mandatory)]
        [System.IO.FileSystemInfo]$_underscore,

        [Parameter(Mandatory)]
        [int]$_index,

        [Parameter(Mandatory)]
        [hashtable]$_exchange,

        [Parameter(Mandatory)]
        [boolean]$_trigger
      )

      [boolean]$itemIsDirectory = ($_underscore.Attributes -band
        [System.IO.FileAttributes]::Directory) -eq [System.IO.FileAttributes]::Directory;

      $endAdapter = New-EndAdapter($_underscore);

      [string]$action = $_exchange['LOOPZ.REMY.ACTION'];
      [boolean]$performDiagnosis = ($_exchange.ContainsKey('LOOPZ.DIAGNOSE') -and
        $_exchange['LOOPZ.DIAGNOSE']);

      # ------------------------------------------ [ Bind action parameters ] ---
      #
      [hashtable]$actionParameters = use-actionParams -exchange $_exchange -endAdapter $endAdapter;

      # -------------------------------------------------- [ Execute action ] ---
      #
      [line]$properties = [line]::new();
      [line[]]$lines = @();
      [hashtable]$signals = $_exchange['LOOPZ.SIGNALS'];
      [string]$errorReason = [string]::Empty;

      try {
        [PSCustomObject]$actionResult = & $action @actionParameters;
        [string]$newItemName = $actionResult.Payload;

      }
      catch {
        [string]$newItemName = $_underscore.Name;
        $errorReason = invoke-HandleError -message $_.Exception.Message -prefix 'Action';

        [PSCustomObject]$actionResult = [PSCustomObject]@{
          FailedReason = $errorReason;
          Success = $false;
        }
      }

      try {
        if ([string]::IsNullOrEmpty($errorReason) -and $_exchange.ContainsKey('LOOPZ.REMY.TRANSFORM')) {
          [scriptblock]$transform = $_exchange['LOOPZ.REMY.TRANSFORM'];

          if ($transform) {
            [string]$transformed = $transform.InvokeReturnAsIs(
              [System.IO.Path]::GetFileNameWithoutExtension($_underscore.Name),
              $newItemName,
              $actionResult.CapturedPattern,
              $_exchange
            );

            if (-not([string]::IsNullOrEmpty($transformed))) {
              $newItemName = $transformed;
            }
          }
        }
      }
      catch {
        $errorReason = invoke-HandleError -message $_.Exception.Message -prefix 'Transform';

        [PSCustomObject]$actionResult = [PSCustomObject]@{
          FailedReason = $errorReason;
          Success      = $false;
        }
      }

      $postResult = invoke-PostProcessing -InputSource $newItemName -Rules $Loopz.Rules.Remy `
        -Signals $signals;

      if ($postResult.Modified) {
        [couplet]$postSignal = Get-FormattedSignal -Name 'REMY.POST' `
          -Signals $signals -Value $postResult.Indication -CustomLabel $postResult.Label;
        $properties.append($postSignal);
        $newItemName = $postResult.TransformResult;
      }

      $newItemName = $endAdapter.GetNameWithExtension($newItemName);
      Write-Debug "Rename-Many; New Item Name: '$newItemName'";

      # -------------------------------------------------- [ Perform Rename ] ---
      #
      [string]$parent = $itemIsDirectory ? $_underscore.Parent.FullName : $_underscore.Directory.FullName;
      [boolean]$nameHasChanged = -not($_underscore.Name -ceq $newItemName);
      [string]$newItemFullPath = Join-Path -Path $parent -ChildPath $newItemName;
      [boolean]$clash = (Test-Path -LiteralPath $newItemFullPath) -and $nameHasChanged;
      [boolean]$trigger = $false;
      [boolean]$whatIf = $_exchange.ContainsKey('WHAT-IF') -and ($_exchange['WHAT-IF']);

      if ($nameHasChanged -and -not($clash) -and [string]::IsNullOrEmpty($errorReason)) {
        try {
          $product = rename-FsItem -From $_underscore -To $newItemName -WhatIf:$whatIf -UndoOperant $operant;

          [UndoRename]$operant = $_exchange.ContainsKey('LOOPZ.REMY.UNDO') `
            ? $_exchange['LOOPZ.REMY.UNDO'] : $null;
          $trigger = $true;
        }
        catch {
          $product = $newItemName;
          $errorReason = invoke-HandleError -message $_.Exception.Message -prefix 'Rename';
        }
      }
      else {
        $product = $_underscore;
      }

      # -------------------------------------------- [ Prepare Display Info ] ---
      #
      [string]$fileSystemItemType = $itemIsDirectory ? 'Directory' : 'File';

      [PSCustomObject]$context = $_exchange['LOOPZ.REMY.CONTEXT'];
      [int]$maxItemMessageSize = $_exchange['LOOPZ.REMY.MAX-ITEM-MESSAGE-SIZE'];
      [string]$normalisedItemMessage = $Context.ItemMessage.replace(
        $Loopz.FsItemTypePlaceholder, $fileSystemItemType);

      [string]$messageLabel = if ($context.psobject.properties.match('ItemMessage') -and `
          -not([string]::IsNullOrEmpty($Context.ItemMessage))) {

        Get-PaddedLabel -Label $($Context.ItemMessage.replace(
            $Loopz.FsItemTypePlaceholder, $fileSystemItemType)) -Width $maxItemMessageSize;
      }
      else {
        $normalisedItemMessage;
      }

      [string]$signalName = $itemIsDirectory ? 'DIRECTORY-A' : 'FILE-A';
      [string]$message = Get-FormattedSignal -Name $signalName `
        -Signals $signals -CustomLabel $messageLabel -Format ' [{1}] {0}';

      [int]$magic = 5;
      [int]$indent = $_exchange['LOOPZ.REMY.FIXED-INDENT'] + $message.Length - $magic;
      $_exchange['LOOPZ.WH-FOREACH-DECORATOR.INDENT'] = $indent;
      $_exchange['LOOPZ.WH-FOREACH-DECORATOR.MESSAGE'] = $message;
      $_exchange['LOOPZ.WH-FOREACH-DECORATOR.PRODUCT-LABEL'] = $(Get-PaddedLabel -Label $(
          $fileSystemItemType) -Width 9);

      if (-not([string]::IsNullOrEmpty($errorReason))) {
        $null = $lines += (New-Line(
            New-Pair(@($_exchange['LOOPZ.REMY.FROM-LABEL'], $_underscore.Name))
          ));

        [couplet]$errorSignal = Get-FormattedSignal -Name 'BAD-A' `
          -Signals $signals -CustomLabel 'Error' -Value $errorReason;

        $errorSignal.Affirm = $true;
        $null = $lines += (New-Line(
            $errorSignal
          ));
      }
      elseif ($trigger) {
        $null = $lines += (New-Line(
            New-Pair(@($_exchange['LOOPZ.REMY.FROM-LABEL'], $_underscore.Name))
          ));
      }
      else {
        if ($clash) {
          Write-Debug "!!! doRenameFsItems; path: '$newItemFullPath' already exists, rename skipped";
          [couplet]$clashSignal = Get-FormattedSignal -Name 'CLASH' `
            -Signals $signals -EmojiAsValue -EmojiOnlyFormat '{0}';
          $properties.append($clashSignal);
        }
        else {
          [couplet]$notActionedSignal = Get-FormattedSignal -Name 'NOT-ACTIONED' `
            -Signals $signals -EmojiAsValue -CustomLabel 'Not Renamed' -EmojiOnlyFormat '{0}';
          $properties.append($notActionedSignal);

          [string]$reason = Get-PsObjectField -Object $actionResult -Field 'FailedReason'
          if (-not([string]::IsNullOrEmpty($reason))) {
            [couplet]$becauseSignal = Get-FormattedSignal -Name 'BECAUSE' `
              -Signals $signals -Value $reason;
            $properties.append($becauseSignal);
          }
          elseif (-not($nameHasChanged)) {
            [couplet]$becauseSignal = Get-FormattedSignal -Name 'BECAUSE' `
              -Signals $signals -Value 'Unchanged';
            $properties.append($becauseSignal);
          }
        }
      }

      if ($whatIf) {
        [couplet]$whatIfSignal = Get-FormattedSignal -Name 'WHAT-IF' `
          -Signals $signals -EmojiAsValue -EmojiOnlyFormat '{0}';
        $properties.append($whatIfSignal);
      }

      # -------------------------------------------------- [ Do diagnostics ] ---
      #
      if ($performDiagnosis -and $actionResult.Diagnostics.Named -and
        ($actionResult.Diagnostics.Named.PSBase.Count -gt 0)) {

        [string]$diagnosticEmoji = Get-FormattedSignal -Name 'DIAGNOSTICS' -Signals $signals `
          -EmojiOnly;

        [string]$captureEmoji = Get-FormattedSignal -Name 'CAPTURE' -Signals $signals `
          -EmojiOnly -EmojiOnlyFormat '[{0}]';

        foreach ($namedItem in $actionResult.Diagnostics.Named) {
          foreach ($namedKey in $namedItem.Keys) {
            [hashtable]$groups = $actionResult.Diagnostics.Named[$namedKey];
            [string[]]$diagnosticLines = @();

            foreach ($groupName in $groups.Keys) {
              [string]$captured = $groups[$groupName];
              [string]$compoundValue = "({0} <{1}>)='{2}'" -f $captureEmoji, $groupName, $captured;
              [string]$namedLabel = Get-PaddedLabel -Label ($diagnosticEmoji + $namedKey);

              $diagnosticLines += $compoundValue;
            }
            $null = $lines += (New-Line(
                New-Pair(@($namedLabel, $($diagnosticLines -join ', ')))
              ));
          }
        }
      }

      # -------------------------------------------------- [ Compose Result ] ---
      #
      [PSCustomObject]$result = [PSCustomObject]@{
        Product = $product;
      }

      $result | Add-Member -MemberType NoteProperty -Name 'Pairs' -Value $properties;

      if ($lines.Length -gt 0) {
        $result | Add-Member -MemberType NoteProperty -Name 'Lines' -Value $lines;
      }

      if ($trigger) {
        $result | Add-Member -MemberType NoteProperty -Name 'Trigger' -Value $true;
      }

      [boolean]$differsByCaseOnly = $newItemName.ToLower() -eq $_underscore.Name.ToLower();
      [boolean]$affirm = $trigger -and ($product) -and -not($differsByCaseOnly);
      if ($affirm) {
        $result | Add-Member -MemberType NoteProperty -Name 'Affirm' -Value $true;
      }

      if (-not([string]::IsNullOrEmpty($errorReason))) {
        $result | Add-Member -MemberType NoteProperty -Name 'ErrorReason' -Value $errorReason;
      }

      return $result;
    } # doRenameFsItems

    [scriptblock]$getResult = {
      param($result)

      $result.GetType() -in @([System.IO.FileInfo], [System.IO.DirectoryInfo]) ? $result.Name : $result;
    }

    [string]$pesterAssertionFailure = 'Expected strings to be the same, but they were different';

    [System.IO.FileSystemInfo[]]$collection = @();

    [Krayon]$_krayon = Get-Krayon
    [Scribbler]$_scribbler = New-Scribbler -Krayon $_krayon -Test:$Test.IsPresent;
  } # begin

  process {
    Write-Debug "=== Rename-Many [$($underscore.Name)] ===";

    $collection += $underscore;
  }

  end {
    Write-Debug '<<< Rename-Many <<<';

    # ------------------------------------------------------ [ Init Phase ] ---
    #
    [hashtable]$signals = $(Get-Signals);
    [hashtable]$theme = $_scribbler.Krayon.Theme;
    [boolean]$locked = Get-IsLocked -Variable $(
      [string]::IsNullOrEmpty($Context.Locked) ? 'LOOPZ_REMY_LOCKED' : $Context.Locked
    );

    [string]$title = $Context.psobject.properties.match('Title') -and `
      -not([string]::IsNullOrEmpty($Context.Title)) `
      ? $Context.Title : 'Rename';

    if ($locked) {
      $title = Get-FormattedSignal -Name 'LOCKED' -Signals $signals `
        -Format '{1} {0} {1}' -CustomLabel $('Locked: ' + $title);
    }

    [boolean]$whatIf = $PSBoundParameters.ContainsKey('WhatIf') -or $locked;
    [PSCustomObject]$containers = [PSCustomObject]@{
      Wide  = [line]::new();
      Props = [line]::new();
    }

    [int]$maxItemMessageSize = $Context.ItemMessage.replace(
      $Loopz.FsItemTypePlaceholder, 'Directory').Length;

    [string]$summaryMessage = $Context.psobject.properties.match('SummaryMessage') -and `
      -not([string]::IsNullOrEmpty($Context.SummaryMessage)) `
      ? $Context.SummaryMessage : 'Rename Summary';

    $summaryMessage = Get-FormattedSignal -Name 'SUMMARY-A' -Signals $signals -CustomLabel $summaryMessage;

    [hashtable]$rendezvous = @{
      'LOOPZ.SCRIBBLER'                       = $_scribbler;
      'LOOPZ.SIGNALS'                         = $signals;

      'LOOPZ.WH-FOREACH-DECORATOR.BLOCK'      = $doRenameFsItems;
      'LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT' = $getResult;

      'LOOPZ.HEADER-BLOCK.CRUMB-SIGNAL'       = 'CRUMB-A';
      'LOOPZ.HEADER-BLOCK.LINE'               = $LoopzUI.DashLine;
      'LOOPZ.HEADER-BLOCK.MESSAGE'            = $title;

      'LOOPZ.SUMMARY-BLOCK.LINE'              = $LoopzUI.EqualsLine;
      'LOOPZ.SUMMARY-BLOCK.MESSAGE'           = $summaryMessage;

      'LOOPZ.REMY.CONTEXT'                    = $Context;
      'LOOPZ.REMY.MAX-ITEM-MESSAGE-SIZE'      = $maxItemMessageSize;
      'LOOPZ.REMY.FIXED-INDENT'               = get-fixedIndent -Theme $theme;
      'LOOPZ.REMY.FROM-LABEL'                 = Get-PaddedLabel -Label 'From' -Width 9;

      'LOOPZ.REMY.USER-PARAMS'                = $PSBoundParameters;
    }

    [string]$adjustedWhole = if ($PSBoundParameters.ContainsKey('Whole')) {
      $Whole.ToLower();
    }
    else {
      [string]::Empty;
    }

    [PSCustomObject]$bootStrapOptions = [PSCustomObject]@{};
    if (-not([string]::IsNullOrEmpty($adjustedWhole))) {
      $bootStrapOptions | Add-Member -MemberType NoteProperty -Name 'Whole' -Value $adjustedWhole;
    }

    [BootStrap]$bootStrap = New-BootStrap `
      -Exchange $rendezvous `
      -Containers @{ Wide = [line]::new(); Props = [line]::new(); } `
      -Options $bootStrapOptions;

    # ------------------------------------------------ [ Primary Entities ] ---
    # (Note: Keep Signal Registry up to date)
    #

    # [Pattern]
    #
    [PSCustomObject]$patternSpec = [PSCustomObject]@{
      Activate       = $PSBoundParameters.ContainsKey('Pattern') -and `
        -not([string]::IsNullOrEmpty($Pattern));
      SpecType       = 'regex';
      Name           = 'Pattern';
      Value          = $Pattern;
      Signal         = 'PATTERN';
      WholeSpecifier = 'p';
      RegExKey       = 'LOOPZ.REMY.PATTERN-REGEX';
      OccurrenceKey  = 'LOOPZ.REMY.PATTERN-OCC';
    }
    $bootStrap.Register($patternSpec);

    if ($PSBoundParameters.ContainsKey('Pattern') -and -not([string]::IsNullOrEmpty($Pattern))) {
      [string]$patternExpression, [string]$patternOccurrence = Resolve-PatternOccurrence $Pattern

      Select-SignalContainer -Containers $containers -Name 'PATTERN' `
        -Value $patternExpression -Signals $signals;
    }

    # [Anchor]
    #
    [PSCustomObject]$anchorSpec = [PSCustomObject]@{
      Activate       = $PSBoundParameters.ContainsKey('Anchor') -and `
        -not([string]::IsNullOrEmpty($Anchor));
      SpecType       = 'regex';
      Name           = 'Anchor';
      Value          = $Anchor;
      Signal         = 'REMY.ANCHOR';
      WholeSpecifier = 'a';
      RegExKey       = 'LOOPZ.REMY.ANCHOR.REGEX';
      OccurrenceKey  = 'LOOPZ.REMY.ANCHOR-OCC';
      Keys           = @{
        'LOOPZ.REMY.ACTION'      = 'Move-Match';
        'LOOPZ.REMY.ANCHOR-TYPE' = 'MATCHED-ITEM';
      }
    }
    $bootStrap.Register($anchorSpec);

    # [AnchorStart]
    #
    [PSCustomObject]$anchorStartSpec = [PSCustomObject]@{
      Activate       = $PSBoundParameters.ContainsKey('AnchorStart') -and `
        -not([string]::IsNullOrEmpty($AnchorStart));
      SpecType       = 'regex';
      Name           = 'AnchorStart';
      Value          = $AnchorStart;
      Signal         = 'REMY.ANCHOR';
      WholeSpecifier = 'a';
      RegExKey       = 'LOOPZ.REMY.ANCHOR.REGEX';
      OccurrenceKey  = 'LOOPZ.REMY.ANCHOR-OCC';
      Keys           = @{
        'LOOPZ.REMY.ACTION'      = 'Move-Match';
        'LOOPZ.REMY.ANCHOR-TYPE' = 'HYBRID-START';
      }
    }
    $bootStrap.Register($anchorStartSpec);

    # [AnchorEnd]
    #
    [PSCustomObject]$anchorEndSpec = [PSCustomObject]@{
      Activate       = $PSBoundParameters.ContainsKey('AnchorEnd') -and `
        -not([string]::IsNullOrEmpty($AnchorEnd));
      SpecType       = 'regex';
      Name           = 'AnchorEnd';
      Value          = $AnchorEnd;
      Signal         = 'REMY.ANCHOR';
      WholeSpecifier = 'a';
      RegExKey       = 'LOOPZ.REMY.ANCHOR.REGEX';
      OccurrenceKey  = 'LOOPZ.REMY.ANCHOR-OCC';
      Keys           = @{
        'LOOPZ.REMY.ACTION'      = 'Move-Match';
        'LOOPZ.REMY.ANCHOR-TYPE' = 'HYBRID-END';
      }
    }
    $bootStrap.Register($anchorEndSpec);

    # [Copy]
    #
    [PSCustomObject]$copySpec = [PSCustomObject]@{
      Activate       = $PSBoundParameters.ContainsKey('Copy') -and `
        -not([string]::IsNullOrEmpty($Copy));
      SpecType       = 'regex';
      Name           = 'Copy';
      Value          = $Copy;
      Signal         = 'COPY-A';
      WholeSpecifier = 'c';
      RegExKey       = 'LOOPZ.REMY.COPY.REGEX';
      OccurrenceKey  = 'LOOPZ.REMY.COPY-OCC';
    }
    $bootStrap.Register($copySpec);

    # [With]
    #
    [PSCustomObject]$withSpec = [PSCustomObject]@{
      Activate    = $PSBoundParameters.ContainsKey('With') -and `
        -not([string]::IsNullOrEmpty($With));
      SpecType    = 'formatter';
      Name        = 'With';
      Value       = $With;
      Signal      = 'WITH';
      SignalValue = $With;
      Keys        = @{
        'LOOPZ.REMY.WITH' = $With;
      }
    }
    $bootStrap.Register($withSpec);

    # [Include]
    #
    [PSCustomObject]$includeSpec = [PSCustomObject]@{
      Activate      = $PSBoundParameters.ContainsKey('Include') -and `
        -not([string]::IsNullOrEmpty($Include));
      SpecType      = 'regex';
      Name          = 'Include';
      Value         = $Include;
      Signal        = 'INCLUDE';
      RegExKey      = 'LOOPZ.REMY.INCLUDE.REGEX';
      OccurrenceKey = 'LOOPZ.REMY.INCLUDE-OCC';
    }
    $bootStrap.Register($includeSpec);

    # [Except]
    #
    [PSCustomObject]$exceptSpec = [PSCustomObject]@{
      Activate      = $PSBoundParameters.ContainsKey('Except') -and `
        -not([string]::IsNullOrEmpty($Except));
      SpecType      = 'regex';
      Name          = 'Except';
      Value         = $Except;
      Signal        = 'EXCLUDE';
      RegExKey      = 'LOOPZ.REMY.EXCLUDE.REGEX';
      OccurrenceKey = 'LOOPZ.REMY.EXCLUDE-OCC';
    }
    $bootStrap.Register($exceptSpec);

    # [Diagnose]
    #
    [PSCustomObject]$diagnoseSpec = [PSCustomObject]@{
      Activate    = $Diagnose.ToBool();
      SpecType    = 'signal';
      Name        = 'Diagnose';
      Value       = $true;
      Signal      = 'DIAGNOSTICS';
      SignalValue = $('[{0}]' -f $signals['SWITCH-ON'].Value);
      Force       = 'Props';
    }
    $bootStrap.Register($diagnoseSpec);

    # [Paste]
    #
    [PSCustomObject]$pasteSpec = [PSCustomObject]@{
      Activate    = $PSBoundParameters.ContainsKey('Paste') -and `
        -not([string]::IsNullOrEmpty($Paste));
      Name        = 'Paste';
      Value       = $Paste;
      SpecType    = 'formatter';
      Signal      = 'PASTE-A';
      SignalValue = $Paste;
      Keys        = @{
        'LOOPZ.REMY.PASTE' = $Paste;
      }
    }
    $bootStrap.Register($pasteSpec);

    # [Append]
    #
    [PSCustomObject]$appendSpec = [PSCustomObject]@{
      Activate    = $PSBoundParameters.ContainsKey('Append') -and `
        -not([string]::IsNullOrEmpty($Append));
      Name        = 'Append';
      Value       = $Append;
      SpecType    = 'formatter';
      Signal      = 'APPEND';
      SignalValue = $Append;
      Keys        = @{
        'LOOPZ.REMY.APPENDAGE'      = $Append;
        'LOOPZ.REMY.ACTION'         = 'Add-Appendage';
        'LOOPZ.REMY.APPENDAGE.TYPE' = 'Append';
      }
    }
    $bootStrap.Register($appendSpec);

    # [Prepend]
    #
    [PSCustomObject]$prependSpec = [PSCustomObject]@{
      Activate    = $PSBoundParameters.ContainsKey('Prepend') -and `
        -not([string]::IsNullOrEmpty($Prepend));
      Name        = 'Prepend';
      Value       = $Prepend;
      SpecType    = 'formatter';
      Signal      = 'PREPEND';
      SignalValue = $Prepend;
      Keys        = @{
        'LOOPZ.REMY.APPENDAGE'      = $Prepend;
        'LOOPZ.REMY.ACTION'         = 'Add-Appendage';
        'LOOPZ.REMY.APPENDAGE.TYPE' = 'Prepend';
      }
    }
    $bootStrap.Register($prependSpec);

    # [Start]
    #
    [PSCustomObject]$startSpec = [PSCustomObject]@{
      Activate    = $PSBoundParameters.ContainsKey('Start') -and $Start;
      Name        = 'Start';
      SpecType    = 'signal';
      Value       = $true;
      Signal      = 'REMY.ANCHOR';
      CustomLabel = 'Start';
      Force       = 'Props';
      SignalValue = $signals['SWITCH-ON'].Value;
      Keys        = @{
        'LOOPZ.REMY.ACTION'      = 'Move-Match';
        'LOOPZ.REMY.ANCHOR-TYPE' = 'START';
      };
    }
    $bootStrap.Register($startSpec);

    # [End]
    #
    [PSCustomObject]$endSpec = [PSCustomObject]@{
      Activate    = $PSBoundParameters.ContainsKey('End') -and $End;
      Name        = 'End';
      SpecType    = 'signal';
      Value       = $true;
      Signal      = 'REMY.ANCHOR';
      CustomLabel = 'End';
      Force       = 'Props';
      SignalValue = $signals['SWITCH-ON'].Value;
      Keys        = @{
        'LOOPZ.REMY.ACTION'      = 'Move-Match';
        'LOOPZ.REMY.ANCHOR-TYPE' = 'END';
      };
    }
    $bootStrap.Register($endSpec);

    # [Drop]
    #
    [PSCustomObject]$dropSpec = [PSCustomObject]@{
      Activate    = $PSBoundParameters.ContainsKey('Drop') -and `
        -not([string]::IsNullOrEmpty($Drop));
      Name        = 'Drop';
      Value       = $Drop;
      SpecType    = 'formatter';
      Signal      = 'REMY.DROP';
      SignalValue = $Drop;
      Force       = 'Wide';
      Keys        = @{
        'LOOPZ.REMY.DROP'   = $Drop;
        'LOOPZ.REMY.MARKER' = $Loopz.Defaults.Remy.Marker;
      }
    }
    $bootStrap.Register($dropSpec);

    # [Novice]
    #
    [PSCustomObject]$noviceSpec = [PSCustomObject]@{
      Activate    = $locked;
      Name        = 'Novice';
      SpecType    = 'signal';
      Signal      = 'NOVICE';
      SignalValue = $signals['SWITCH-ON'].Value;
      Force       = 'Wide';
    }
    $bootStrap.Register($noviceSpec);

    # [Transform]
    #
    [PSCustomObject]$transformSpec = [PSCustomObject]@{
      Activate    = $PSBoundParameters.ContainsKey('Transform') -and $Transform;
      SpecType    = 'signal';
      Name        = 'Transform';
      Signal      = 'TRANSFORM';
      SignalValue = $signals['SWITCH-ON'].Value;
      Force       = 'Wide';
      Keys        = @{
        'LOOPZ.REMY.TRANSFORM' = $Transform;
      }
    }
    $bootStrap.Register($transformSpec);

    # [Undo]
    #
    [PSCustomObject]$operantOptions = [PSCustomObject]@{
      ShortCode     = $Context.OperantShortCode;
      OperantName   = 'UndoRename';
      Shell         = 'PoShShell';
      BaseFilename  = 'undo-rename';
      DisabledEnVar = $Context.UndoDisabledEnVar;
    }
    [UndoRename]$operant = Initialize-ShellOperant -Options $operantOptions -DryRun:$whatIf;

    [PSCustomObject]$undoSpec = [PSCustomObject]@{
      Activate    = $true;
      SpecType    = 'signal';
      Name        = 'Undo';
      Signal      = 'REMY.UNDO';
      SignalValue = $($operant ? $operant.Shell.FullPath : $signals['SWITCH-OFF'].Value);
      Force       = 'Wide';
      Keys        = @{
        'LOOPZ.REMY.UNDO' = $operant;
      }
    }
    $bootStrap.Register($undoSpec);

    # [Relation]
    #
    [PSCustomObject]$relationSpec = [PSCustomObject]@{
      Activate = $PSBoundParameters.ContainsKey('Relation') -and `
        -not([string]::IsNullOrEmpty($Relation));
      Name     = 'Relation';
      SpecType = 'simple';
      Value    = $Relation;
      Keys     = @{
        'LOOPZ.REMY.RELATION' = $Relation;
      }
    }
    $bootStrap.Register($relationSpec);

    # [Cut]
    #
    [PSCustomObject]$cutSpec = [PSCustomObject]@{
      Activate       = $PSBoundParameters.ContainsKey('Cut') -and $Cut;
      SpecType       = 'regex';
      Name           = 'Cut';
      Value          = $Cut;
      Signal         = 'CUT-A';
      WholeSpecifier = 'u';
      RegExKey       = 'LOOPZ.REMY.CUT-REGEX';
      OccurrenceKey  = 'LOOPZ.REMY.CUT-OCC';
      Keys           = @{
        'LOOPZ.REMY.ACTION'      = 'Move-Match';
        'LOOPZ.REMY.ANCHOR-TYPE' = 'CUT';
      }
    }
    $bootStrap.Register($cutSpec);

    # ------------------------------------------------------- [ Relations ] ---
    #

    # [IsMove] (Doesn't need to define the action, its simply a flag. To determine
    # if the operation is a move, can use the presence of this entity as an indicator)
    #
    [PSCustomObject]$isMoveSpec = [PSCustomObject]@{
      Activator = [scriptblock] {
        [OutputType([boolean])]
        param(
          [hashtable]$Entities,
          [hashtable]$Relations
        )

        [boolean]$result = $Entities.ContainsKey('Anchor') -or `
          $Entities.ContainsKey('Start') -or $Entities.ContainsKey('End') -or `
          $Entities.ContainsKey('AnchorStart') -or $Entities.ContainsKey('AnchorEnd');

        return $result;
      }
      Name      = 'IsMove';
      SpecType  = 'simple';
    }

    # [IsUpdate]
    #
    [PSCustomObject]$isUpdateSpec = [PSCustomObject]@{
      Activator = [scriptblock] {
        [OutputType([boolean])]
        param(
          [hashtable]$Entities,
          [hashtable]$Relations
        )
        [boolean]$result = $(-not($Relations.Contains('IsMove')) -and `
            -not($Entities.Contains('Append')) -and `
            -not($Entities.Contains('Prepend')) -and `
            -not($Entities.Contains('Cut'))
        );
        return $result;
      }
      Name      = 'IsUpdate';
      SpecType  = 'simple';
      Keys      = @{
        'LOOPZ.REMY.ACTION' = 'Update-Match';
      }
    }

    # Bootstrap ***
    #
    $null = $bootStrap.Build(@($isMoveSpec, $isUpdateSpec));

    # --------------------------------------- [ Bootstrap dependent setup ] ---
    #

    [RegexEntity]$patternEntity = $bootStrap.Get('Pattern');
    if ($bootStrap.Contains('IsMove')) {
      # !!! This is now redundant; replace-all functionality can no longer be invoked.
      # This will be implemented as a separate derivative command that uses Transform.
      #
      if ($patternEntity -and $patternEntity.Occurrence -eq '*') {
        [string]$errorMessage = "'Pattern' wildcard prohibited for move operation (Anchor/Start/End).`r`n";
        $errorMessage += "Please use a digit, 'f' (first) or 'l' (last) for Pattern Occurrence";
        Write-Error $errorMessage -ErrorAction Stop;
      }
    }

    [RegExEntity]$ie = $bootStrap.Get('Include');
    [regex]$includedRegEx = ${ie}?.get_RegEx();

    [RegExEntity]$ee = $bootStrap.Get('Except');
    [regex]$exceptRegEx = ${ee}?.get_RegEx();

    [regex]$patternRegEx = ${patternEntity}?.get_RegEx();

    [scriptblock]$clientCondition = $Condition;
    [scriptblock]$compoundCondition = {
      param(
        [System.IO.FileSystemInfo]$pipelineItem
      )

      [boolean]$clientResult = $clientCondition.InvokeReturnAsIs($pipelineItem);
      [boolean]$isStart = $bootStrap.Contains('Start');
      [boolean]$isEnd = $bootStrap.Contains('End');

      [boolean]$isAlreadyAnchoredAt = if ($isStart -or $isEnd) {
        [hashtable]$anchoredParameters = @{
          'Source'     = $pipelineItem.Name;
          'Expression' = $patternRegEx;
          'Occurrence' = $patternEntity.Occurrence;
        }

        if ($isStart) {
          $anchoredParameters['Start'] = $true;
        }
        else {
          $anchoredParameters['End'] = $true;
        }

        Test-IsAlreadyAnchoredAt @anchoredParameters;
      }
      else {
        $false;
      }

      return $($clientResult -and -not($isAlreadyAnchoredAt));
    } # compoundCondition

    [scriptblock]$invokeCondition = {
      param(
        [System.IO.FileSystemInfo]$pipelineItem
      )
      # Inside the scope of this script block, $Condition is assigned to Invoke-ForeachFsItem's
      # version of the Condition parameter which is this scriptblock and thus results in a stack
      # overflow due to infinite recursion. We need to use a temporary variable so that
      # the client's Condition (Rename-Many) is not accidentally hidden.
      #
      [boolean]$isIncluded = ($null -ne $includedRegEx) ? $includedRegEx.IsMatch($pipelineItem.Name) : $true;
      [boolean]$patternIsMatch = ($null -ne $patternRegEx) ? ($patternRegEx.IsMatch($pipelineItem.Name)) : $true;
      [boolean]$notExcluded = ($null -ne $exceptRegEx) ? -not($exceptRegEx.IsMatch($pipelineItem.Name)) : $true;

      return $patternIsMatch -and $isIncluded -and `
        $notExcluded -and $compoundCondition.InvokeReturnAsIs($pipelineItem);
    }

    # ------------------------------------------------- [ Execution Phase ] ---
    #

    [hashtable]$feParameters = @{
      'Condition' = $invokeCondition;
      'Exchange'  = $rendezvous;
      'Header'    = $LoopzHelpers.HeaderBlock;
      'Summary'   = $LoopzHelpers.SummaryBlock;
      'Block'     = $LoopzHelpers.WhItemDecoratorBlock;
    }

    if ($PSBoundParameters.ContainsKey('File')) {
      $feParameters['File'] = $true;
    }
    elseif ($PSBoundParameters.ContainsKey('Directory')) {
      $feParameters['Directory'] = $true;
    }

    if ($PSBoundParameters.ContainsKey('Top')) {
      $feParameters['Top'] = $Top;
    }

    if ($whatIf -or $Diagnose.ToBool()) {
      $rendezvous['WHAT-IF'] = $true;
    }

    if ($Diagnose.ToBool()) {
      $rendezvous['LOOPZ.DIAGNOSE'] = $true;
    }

    [System.Exception]$deferredException = $null;
    try {
      $null = $collection | Invoke-ForeachFsItem @feParameters;
    }
    catch {
      # ctrl-c doesn't invoke an exception, it just abandons processing,
      # ending up in the finally block.
      #
      $deferredException = $_.Exception;
      Write-Host $_.Exception.StackTrace;
    }
    finally {
      # catch ctrl-c
      if ($operant -and -not($whatIf)) {
        $operant.finalise();
      }
    }

    if ($deferredException) {
      throw $deferredException;
    }
  } # end
} # Rename-Many

function Select-Patterns {
  <#
  .NAME
    Select-Patterns
 
  .SYNOPSIS
    This is a simplified yet enhanced version of standard Select-String command (or
  the grep command on Linux/Unix/mac) that allows the user to run multiple searches
  which are chained together to produce its final result.
 
  .DESCRIPTION
    The main rationale for using this command ("greps" as in multiple grep invokes) instead
  of Select-String, is for the provision of multiple patterns. Now, Select-String does
  allow the user to provide multiple Patterns, but the result is a logical OR rather
  than an AND. greps uses AND by piping the result of each individual Pattern search to
  the next Pattern search so the result is those lines found that match all the patterns
  provided rather than all lines that match 1 or more of the patterns. The user can achieve
  OR functionality by using a | inside the same string; for example to find all lines
  that contain any of the patterns 'red', 'green' or 'blue', they could just use
  'red|green|blue'.
    At the end of the run, greps displays the full command (containing multiple pipeline
  legs, one for each pattern provided). If so required, the user can re-run the command
  by running the full command which is displayed and providing different parameters not
  directly supported by greps.
    'greps', does not currently support input from the pipeline. Perhaps this will be
  implemented in a future release.
    At some point in the future, it is intended to further enhance greps using a coloured
  output, whereby a colour is assigned to each pattern and that colour is used to render the
  result. So where the user has provided multiple patterns, currently, only the first pattern
  is highlighted in the result. With the coloured enhancement, the user will be able to see
  all pattern matches in the result with each match displayed in the corresponding allocated
  colour.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER filter
    Defines which files are considered in the search. It can be a path with a wildcard or
  simply a wildcard. If its just a wildcard (eg *.txt), then files considered will be from
  the current directory only.
    The user can define a default filter in the environment as variable 'LOOPZ_GREPS_FILTER'
  which should be a glob such as '*.txt' to represent all text files. If no filter parameter
  is supplied to the greps invoke, then the filter is defined by the value of
  'LOOPZ_GREPS_FILTER'.
 
  .PARAMETER Patterns
    An array of patterns. The result shows all lines that match all the patterns specified.
  An individual pattern can be prefixed with a not op: '!', which means exclude those lines
  which match the subsequent pattern; it is a more succinct way of specifying the -NotMatch
  operator on Select-String. The '!' is not part of the pattern.
 
  .EXAMPLE 1
    Show lines in all .txt files in the current directory files that contain the patterns
  'red' and 'blue':
  greps red, blue *.txt
 
  .EXAMPLE 2
    Show lines in all .txt files in home directory that contain the patterns 'green lorry' and
  'yellow lorry':
  greps 'green lorry', 'yellow lorry' ~/*.txt
 
  .EXAMPLE 3
    Show lines in all files defined in environment as 'LOOPZ_GREPS_FILTER' that contains
  'foo' but not 'bar':
  greps foo, !bar
 
  #>

  [CmdletBinding()]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')]
  [Alias("greps")]
  param
  (
    [parameter(Mandatory = $true, Position = 0)]
    [ValidateNotNullOrEmpty()]
    [String[]]$Patterns,

    [parameter(Position = 1)]
    [String]$Filter = $(Get-EnvironmentVariable -Variable 'LOOPZ_GREPS_FILTER' -Default './*.*'),

    [Parameter()]
    [switch]$Test
  )
  function build-command {
    [OutputType([string])]
    param(
      [string]$Pattern,
      [string]$Filter,
      [switch]$Pipe,
      [string]$NotOpSymbol = '!'
    )
    [string]$platform = Get-PlatformName;
    [System.Text.StringBuilder]$builder = [System.Text.StringBuilder]::new();

    if ($Pipe.ToBool()) {
      $null = $builder.Append(' | ');
    }

    if ($platform -eq 'windows') {
      $null = $builder.Append('select-string ');
      if ($pattern.StartsWith($NotOpSymbol)) {
        $null = $builder.Append($('-notmatch -pattern "{0}" ' -f $Pattern.Substring(1)));
      }
      else {
        $null = $builder.Append($('-pattern "{0}" ' -f $Pattern));
      }
    }
    else {
      $builder.Append('grep ');
      if ($pattern.StartsWith($NotOpSymbol)) {
        $null = $builder.Append($('-v -i "{0}" ' -f $Pattern.Substring(1)));
      }
      else {
        $null = $builder.Append($('-i "{0}" ' -f $Pattern));
      }
    }

    if (-not([string]::IsNullOrWhiteSpace($Filter))) {
      $null = $builder.Append("$Filter ");
    }

    return $builder.ToString();
  } # build-command

  [boolean]$first = $true;
  [string]$command = -join $(foreach ($pat in $Patterns) {
      ($first) `
        ? $(build-command -Pattern $Patterns[0] -Filter $Filter) `
        : $(build-command -Pipe -Pattern $pat);

      $first = $false;
    });

  [hashtable]$signals = $(Get-Signals);
  [Scribbler]$scribbler = New-Scribbler -Test:$Test.IsPresent;
    
  [couplet]$formattedSignal = Get-FormattedSignal -Name 'GREPS' -Value $command -Signals $signals;

  # This is one of those very few situations where we don't re-direct to null the result of executing a
  # command; this is the command's raison d'etre.
  #
  Invoke-Expression $command;

  [string]$keySnippet = $scribbler.Snippets('blue');
  [string]$arrowSnippet = $scribbler.Snippets('red');
  [string]$signalSnippet = $scribbler.Snippets('green');
  [string]$lnSnippet = $scribbler.Snippets('Ln');

  $scribbler.Scribble(
    $("$($keySnippet)$($formattedSignal.Key)$($arrowSnippet) --> $($signalSnippet)$($command)$($lnSnippet)")
  );

  $scribbler.Flush();
}

function Show-Signals {
  <#
  .NAME
    Show-Signals
 
  .SYNOPSIS
    Shows all defined signals, including user defined signals.
 
  .DESCRIPTION
    User can override signal definitions in their profile, typically using the provided
  function Update-CustomSignals.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER SourceSignals
    Hashtable containing signals to be displayed.
 
  .PARAMETER Registry
    Hashtable containing information concerning commands usage of signals.
 
  .PARAMETER Include
    Provides a filter. When specified, only the applications included in the list
  will be shown.
 
  .EXAMPLE 1
 
  Show-Signals
 
  Show signal definitions and references for all registered commands
 
  .EXAMPLE 2
 
  Show-Signals -Include remy, ships
 
  Show the signal definitions and references for commands 'remy' and 'ships' only
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')]
  param(
    [Parameter()]
    [hashtable]$SourceSignals = $(Get-Signals),

    [Parameter()]
    [hashtable]$Registry = $($Loopz.SignalRegistry),

    [Parameter()]
    [string[]]$Include = @(),

    [Parameter()]
    [switch]$Test
  )

  function get-GraphemeLength {
    param(
      [Parameter(Position = 0)]
      [string]$Value
    )
    [System.Text.StringRuneEnumerator]$enumerator = $Value.EnumerateRunes();
    [int]$count = 0;

    while ($enumerator.MoveNext()) {
      $count++;
    }

    return $count;
  }

  function get-TextElementLength {
    param(
      [Parameter(Position = 0)]
      [string]$Value
    )
    # This function is of no use, but leaving it in for reference, until the
    # issue of bad emoji behaviour has been fixed.
    #
    [System.Globalization.TextElementEnumerator]$enumerator = `
      [System.Globalization.StringInfo]::GetTextElementEnumerator($Value);

    [int]$count = 0;

    while ($enumerator.MoveNext()) {
      $count++;
    }

    return $count;
  }

  [hashtable]$theme = Get-KrayolaTheme;
  [Krayon]$krayon = New-Krayon -Theme $theme;
  [Scribbler]$scribbler = New-Scribbler -Krayon $krayon -Test:$Test.IsPresent;

  if ($Include.Count -eq 0) {
    $Include = $Registry.PSBase.Keys;
  }

  [scriptblock]$renderCell = {
    [OutputType([boolean])]
    param(
      [string]$column,
      [string]$value,
      [PSCustomObject]$row,
      [PSCustomObject]$options,
      [object]$scribbler,
      [int]$counter
    )
    [boolean]$result = $true;
    [boolean]$alternate = ($counter % 2) -eq 1;
    [string]$backSnippet = $alternate `
      ? $options.Custom.Snippets.ResetAlternateBack 
    : $options.Custom.Snippets.ResetDefaultBack;

    switch -Regex ($column) {
      'Name' {
        [string]$nameSnippet = ($row.Length.Trim() -eq '2') `
          ? $options.Custom.Snippets.Standard : $options.Custom.Snippets.Cell;

        $scribbler.Scribble("$($backSnippet)$($nameSnippet)$($value)");
        break;
      }

      'Icon' {
        # This tweak is required because unfortunately, some emojis are ill
        # defined causing misalignment.
        #
        if ($options.Custom.UsingEmojis) {
          [string]$length = $row.Length.Trim();
          if ($length -eq '1') {
            # Chop off the last character
            #
            $value = $value -replace ".$";
          }
        }
        $scribbler.Scribble("$($backSnippet)$($value)");
        break;
      }

      'Length|GraphLn' {
        $scribbler.Scribble("$($backSnippet)$($value)");
        break;
      }

      'Custom' {
        [string]$padded = Format-BooleanCellValue -Value $value -TableOptions $options;
        $null = $scribbler.Scribble("$($backSnippet)$($padded)$($endOfRowSnippet)");

        break;
      }

      default {
        if ($Registry.ContainsKey($column)) {
          [string]$padded = Format-BooleanCellValue -Value $value -TableOptions $options;
          $null = $scribbler.Scribble("$($backSnippet)$($padded)");
        }
        else {
          $scribbler.Scribble("$($backSnippet)$($value)");
        }
      }
    }

    return $result;
  } # RenderCell

  [string]$resetSnippet = $scribbler.Snippets(@('Reset'));
  [string]$endOfRowSnippet = $resetSnippet;
  [string]$headerSnippet = $scribbler.Snippets(@('white', 'bgDarkBlue'));
  [string]$underlineSnippet = $scribbler.Snippets(@('darkGray'));
  [string]$standardSnippet = $scribbler.Snippets(@('darkGreen'));
  [string]$alternateBackSnippet = $scribbler.Snippets(@('bgDarkGray'));
  [string]$defaultBackSnippet = $scribbler.Snippets(@(
      'bg' + $scribbler.Krayon.getDefaultBack()
    ));

  # Make sure that 'Custom' is always the last column
  #
  [string[]]$columnSelection = @('Name', 'Label', 'Icon', 'Length', 'GraphLn') + $(
    $Registry.PSBase.Keys | Where-Object { $Include -contains $_ } | ForEach-Object { $_; }
  ) + 'Custom';

  [PSCustomObject]$custom = [PSCustomObject]@{
    Snippets    = [PSCustomObject]@{
      Header             = $headerSnippet;
      Underline          = $underlineSnippet;
      Standard           = $standardSnippet;
      ResetAlternateBack = "$($resetSnippet)$($alternateBackSnippet)";
      AlternateBack      = "$($alternateBackSnippet)";
      ResetDefaultBack   = "$($resetSnippet)$($defaultBackSnippet)";
    }
    Colours     = [PSCustomObject]@{
      Title = 'darkYellow';
    }
    UsingEmojis = Test-HostSupportsEmojis;
  }

  [PSCustomObject]$tableOptions = Get-TableDisplayOptions -Select $columnSelection `
    -Signals $SourceSignals -Scribbler $scribbler -Custom $custom;

  [string[]]$customKeys = $Loopz.CustomSignals.PSBase.Keys;
  [PSCustomObject[]]$source = $($SourceSignals.GetEnumerator() | ForEach-Object {
      [string]$signalKey = $_.Key;

      [PSCustomObject]$signalDef = [PSCustomObject]@{
        Name    = $_.Key;
        Label   = $_.Value.Key;
        Icon    = $_.Value.Value;
        Length  = $_.Value.Value.Length;
        GraphLn = $(get-GraphemeLength $_.Value.Value);
        Custom  = $($customKeys -contains $signalKey);
      }

      # Now add onto the signal definition, the dependent registered commands
      #
      $Registry.GetEnumerator() | Foreach-Object {
        [string]$commandAlias = $_.Key;
        [boolean]$isUsedBy = $_.Value -contains $signalKey;
        $signalDef | Add-Member -MemberType NoteProperty -Name $commandAlias -Value $isUsedBy;
      }

      $signalDef;
    });

  # NB: The $_ here is within the context of the Select-Object statement just below
  #
  [array]$selection = @(
    'Name'
    @{Name = 'Label'; Expression = { $_.Label }; }
    @{Name = 'Icon'; Expression = { $_.Icon }; }
    @{Name = 'Length'; Expression = { $_.Length }; }
    @{Name = 'GraphLn'; Expression = { $_.GraphLn }; }
    @{Name = 'Custom'; Expression = { $_.Custom }; }
  );

  $selection += $(foreach ($alias in $Registry.PSBase.Keys) {
      @{Name = $alias; Expression = { $_.$alias }; }
    })

  [PSCustomObject[]]$resultSet = ($source | Select-Object -Property $selection);
  [hashtable]$fieldMetaData = Get-FieldMetaData -Data $resultSet;

  [hashtable]$headers, [hashtable]$tableContent = Get-AsTable -MetaData $fieldMetaData `
    -TableData $source -Options $tableOptions;

  [string]$title = 'Signal definitions and references';
  Show-AsTable -MetaData $fieldMetaData -Headers $headers -Table $tableContent `
    -Scribbler $scribbler -Options $tableOptions -Render $renderCell -Title $title;

  $scribbler.Scribble("$($tableOptions.Snippets.Ln)");

  $scribbler.Flush();
} # Show-Signals

function Update-CustomSignals {
  <#
  .NAME
    Update-CustomSignals
 
  .SYNOPSIS
    Allows user to override the emoji's for commands
 
  .DESCRIPTION
    A user may want to customise the appear of commands that use signals in their
  display. The user can specify overrides for any of the declared signals (See
  Show-Signals). Typically, the user should invoke this in their profile script.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Signals
    A hashtable containing signal overrides.
 
  .EXAMPLE 1
  Override signals 'PATTERN' and 'LOCKED' with custom emojis.
 
  [hashtable]$myOverrides = @{
    'PATTERN' = $(kp(@('Capture', '👾')));
    'LOCKED' = $(kp(@('No soup for you', '🥣')));
  }
  Update-CustomSignals -Signals $myOverrides
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
  param(
    [Parameter(Mandatory)]
    [hashtable]$Signals
  )

  if ($Signals -and ($Signals.Count -gt 0)) {
    if ($Loopz) {
      if (-not($Loopz.CustomSignals)) {
        $Loopz.CustomSignals = @{}
      }

      $Signals.GetEnumerator() | ForEach-Object {
        if ($_.Value -and ($_.Value -is [couplet])) {
          $Loopz.CustomSignals[$_.Key] = $_.Value;
        }
        else {
          Write-Warning "Loopz: Skipping custom signal('$($_.Key)'); not a valid couplet/pair: -->$($_.Value)<--";
        }
      }
    }
  }
}

function Add-Appendage {
  <#
  .NAME
    Add-Appendage
 
  .SYNOPSIS
    The core appendage action function principally used by Rename-Many. Adds either
  a prefix or suffix to the Value.
 
  .DESCRIPTION
    Returns a new string that reflects the addition of an appendage, which can be Prepend
  or Append. The appendage itself can be static text, or can act like a formatter supporting
  Copy named group reference s, if present. The user can decide to reference the whole Copy
  match with ${_c}, or if it contains named captures, these can be referenced inside the
  appendage as ${<group-name-ref>}
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Value
    The source value against which regular expressions are applied.
 
  .PARAMETER Appendage
    String to either prepend or append to Value. Supports named captures inside Copy regex
  parameter.
 
  .PARAMETER Type
    Denotes the appendage type, can be 'Prepend' or 'Append'.
 
  .PARAMETER Copy
    Regular expression string applied to $Value, indicating a portion which should be copied and
  inserted into the Appendage. The match defined by $Copy is stored in special variable ${_c} and
  can be referenced as such from $Appendage.
 
  .PARAMETER CopyOccurrence
    Can be a number or the letters f, l
  * f: first occurrence
  * l: last occurrence
  * <number>: the nth occurrence
 
  .PARAMETER Diagnose
    switch parameter that indicates the command should be run in WhatIf mode. When enabled
  it presents additional information that assists the user in correcting the un-expected
  results caused by an incorrect/un-intended regular expression. The current diagnosis
  will show the contents of named capture groups that they may have specified. When an item
  is not renamed (usually because of an incorrect regular expression), the user can use the
  diagnostics along side the 'Not Renamed' reason to track down errors.
 
  #>

  [OutputType([PSCustomObject])]
  param(
    [Parameter(Mandatory)]
    [string]$Value,

    [Parameter(Mandatory)]
    [string]$Appendage,

    [Parameter(Mandatory)]
    [ValidateSet('Prepend', 'Append')]
    [string]$Type,

    [Parameter()]
    [System.Text.RegularExpressions.RegEx]$Copy,

    [Parameter()]
    [ValidateScript( { ($_ -ne '*') -and ($_ -ne '0') })]
    [string]$CopyOccurrence = 'f',

    [Parameter()]
    [switch]$Diagnose
  )

  [string]$failedReason = [string]::Empty;
  [string]$result = [string]::Empty;
  [PSCustomObject]$groups = [PSCustomObject]@{
    Named = @{}
  }

  if ($PSBoundParameters.ContainsKey('Copy')) {
    if ($Value -match $Copy) {
      [string]$appendageContent = $Appendage;
      [hashtable]$parameters = @{
        'Source'       = $Value
        'PatternRegEx' = $Copy
        'Occurrence'   = ($PSBoundParameters.ContainsKey('CopyOccurrence') ? $CopyOccurrence : 'f')
      }

      # With this implementation, it is up to the user to supply a regex proof
      # pattern, so if the Copy contains regex chars which must be treated literally, they
      # must pass in the string pre-escaped: -Copy $(esc('some-pattern') + 'other stuff').
      #
      [string]$capturedCopy, $null, `
        [System.Text.RegularExpressions.Match]$copyMatch = Split-Match @parameters;

      [Hashtable]$copyCaptures = get-Captures -MatchObject $copyMatch;

      if ($Diagnose.ToBool()) {
        $groups.Named['Copy'] = $copyCaptures;
      }
      $appendageContent = $appendageContent.Replace('${_c}', $capturedCopy);

      # Now cross reference the Copy group references
      #
      $appendageContent = Update-GroupRefs -Source $appendageContent -Captures $copyCaptures;

      $result = if ($Type -eq 'Prepend') {
        $($appendageContent + $Value);
      }
      elseif ($Type -eq 'Append') {
        $($Value + $appendageContent);
      }
    }
    else {
      # Copy doesn't match so abort and return unmodified source
      #
      $failedReason = 'Copy Match';
    }
  }
  else {
    $result = if ($Type -eq 'Prepend') {
      $($Appendage + $Value);
    }
    elseif ($Type -eq 'Append') {
      $($Value + $Appendage);
    }
    else {
      throw [System.Management.Automation.MethodInvocationException]::new(
        "Add-Appendage: Invalid Appendage Type: '$Type', Appendage: '$Appendage'");
    }
  }

  [boolean]$success = $([string]::IsNullOrEmpty($failedReason));
  if (-not($success)) {
    $result = $Value;
  }

  [PSCustomObject]$appendageResult = [PSCustomObject]@{
    Payload = $result;
    Success = $success;
  }

  if (-not([string]::IsNullOrEmpty($failedReason))) {
    $appendageResult | Add-Member -MemberType NoteProperty -Name 'FailedReason' -Value $failedReason;
  }

  if ($Diagnose.ToBool() -and ($groups.Named.Count -gt 0)) {
    $appendageResult | Add-Member -MemberType NoteProperty -Name 'Diagnostics' -Value $groups;
  }

  return $appendageResult;
}

function Format-Escape {
  <#
  .NAME
    Format-Escape
 
  .SYNOPSIS
    Escapes the regular expression specified. This is just a wrapper around the
  .net regex::escape method, but gives the user a much easier way to
  invoke it from the command line.
 
  .DESCRIPTION
    Various functions in Loopz have parameters that accept a regular expression. This
  function gives the user an easy way to escape the regex, without them having to do
  this manually themselves which could be tricky to get right depending on their
  requirements. NB: an alternative to using the 'esc' function is to add a ~ to the start
  of the pattern. The tilde is not taken as part of the pattern and is stripped off.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Source
    The source string to escape.
 
  .EXAMPLE 1
  Rename-Many -Pattern $(esc('(123)'))
 
  Use the 'esc' alias with the Rename-Many command, escaping the regex characters in the Pattern definition
 
  .EXAMPLE 2
  Rename-Many -Pattern '~(123)'
 
  Use a leading '~' in the pattern definition, to escape the whole value.
 
  .EXAMPLE 3
  Rename-Many -Pattern $('esc(123)' + '_(?<n>\d{3})')
 
  Split the pattern into the parts that need escaping and those that don't. This will
  match
  #>

  [Alias('esc')]
  [OutputType([string])]
  param(
    [Parameter(Position = 0, Mandatory)]$Source
  )
  [regex]::Escape($Source);
}

function Get-FormattedSignal {
  <#
  .NAME
    Get-FormattedSignal
 
  .SYNOPSIS
    Controls and standardises the way that signals are displayed.
 
  .DESCRIPTION
    This function enables the display of key/value pairs where the key includes
  an emoji. The value may also include the emoji depending on how the function
  is used.
    Generally, this function returns either a Pair object or a single string.
  The user can define a format string (or simply use the default) which controls
  how the signal is displayed. If the function is invoked without a Value, then
  a formatted string is returned, otherwise a pair object is returned.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER CustomLabel
    An alternative label to display overriding the signal's defined label.
 
  .PARAMETER EmojiAsValue
    switch which changes the result so that the emoji appears as part of the
  value as opposed to the key.
 
  .PARAMETER EmojiOnly
    Changes what is returned, to be a single value only, formatted as EmojiOnlyFormat.
 
  .PARAMETER EmojiOnlyFormat
    When the switch EmojiOnly is enabled, EmojiOnlyFormat defines the format used to create
  the result. Should contain at least 1 occurrence of {1} representing the
  emoji.
 
  .PARAMETER Format
    A string defining the format defining how the signal is displayed. Should
  contain either {0} representing the signal's emoji or {1} the label. They
  can appear as many time as is required, but there should be at least either
  one of these.
 
  .PARAMETER Name
    The name of the signal
 
  .PARAMETER Signals
    The signals hashtable collection from which to select the signal from.
 
  .PARAMETER Value
    A string defining the Value displayed when the signal is a Key/Value pair.
 
  #>

  param(
    [Parameter(Mandatory)]
    [string]$Name,

    [Parameter()]
    [string]$Format = '[{1}] {0}', # 0=Label, 1=Emoji

    [Parameter()]
    [string]$Value,

    [Parameter()]
    [hashtable]$Signals = $(Get-Signals),

    [Parameter()]
    [string]$CustomLabel,

    [Parameter()]
    [string]$EmojiOnlyFormat = '[{0}] ',

    [Parameter()]
    [switch]$EmojiOnly,

    [Parameter()]
    [switch]$EmojiAsValue
  )

  [couplet]$signal = $Signals.ContainsKey($Name) `
    ? $Signals[$Name] `
    : $(New-Pair(@($("??? ({0})" -f $Name), $(Resolve-ByPlatform -Hash $Loopz.MissingSignal).Value)));

  [string]$label = ($PSBoundParameters.ContainsKey('CustomLabel') -and
    (-not([string]::IsNullOrEmpty($CustomLabel)))) ? $CustomLabel : $signal.Key;

  [string]$formatted = $EmojiOnly.ToBool() `
    ? $EmojiOnlyFormat -f $signal.Value : $Format -f $label, $signal.Value;

  $result = if ($PSBoundParameters.ContainsKey('Value')) {
    New-Pair($formatted, $Value);
  }
  elseif ($EmojiAsValue.ToBool()) {
    New-Pair($label, $($EmojiOnlyFormat -f $signal.Value));
  }
  else {
    $formatted;
  }

  return $result;
}

function Get-PaddedLabel {
  <#
  .NAME
    Get-PaddedLabel
 
  .SYNOPSIS
    Controls and standardises the way that signals are displayed.
 
  .DESCRIPTION
    Pads out a string with leading or trailing spaces depending on
  alignment.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Align
    Left or right alignment of the label.
 
  .PARAMETER Label
    The string to be padded
 
  .PARAMETER Width
    Size of the field into which the label is to be placed.
 
  #>

  [OutputType([string])]
  param(
    [Parameter()]
    [string]$Label,

    [Parameter()]
    [string]$Align = 'right',

    [Parameter()]
    [int]$Width
  )
  [int]$length = $Label.Length;

  [string]$result = if ($length -lt $Width) {
    [string]$padding = [string]::new(' ', $($Width - $length))
    ($Align -eq 'right') ? $($padding + $Label) : $($Label + $padding);
  } else {
    $Label;
  }

  $result;
}

function Get-Signals {
  <#
  .NAME
    Get-Signals
 
  .SYNOPSIS
    Returns a copy of the Signals hashtable.
 
  .DESCRIPTION
    The signals returned include the user defined signal overrides.
 
  NOTE: 3rd party commands need to register their signal usage with the signal
  registry. This can be done using command Register-CommandSignals and would
  be best performed at module initialisation stage invoked at import time.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Custom
    The hashtable instance containing custom overrides. Does not need to be
  specified by the client as it is defaulted.
 
  .PARAMETER SourceSignals
    The hashtable instance containing the main signals. Does not need to be
  specified by the client as it is defaulted.
 
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')]
  [OutputType([hashtable])]
  param(
    [Parameter()]
    [hashtable]$SourceSignals = $global:Loopz.Signals,

    [Parameter()]
    [hashtable]$Custom = $global:Loopz.CustomSignals
  )

  [hashtable]$result = $SourceSignals.Clone();

  if ($Custom -and ($Custom.Count -gt 0)) {
    $Custom.GetEnumerator() | ForEach-Object {
      try {
        $result[$_.Key] = $_.Value;
      }
      catch {
        Write-Error "Skipping custom signal: '$($_.Key)'";
      }
    }
  }

  return $result;
}
$global:LoopzHelpers = @{
  # Helper Script Blocks
  #
  WhItemDecoratorBlock = [scriptblock] {
    param(
      [Parameter(Mandatory)]
      $_underscore,

      [Parameter(Mandatory)]
      [int]$_index,

      [Parameter(Mandatory)]
      [hashtable]$_exchange,

      [Parameter(Mandatory)]
      [boolean]$_trigger
    )

    return Write-HostFeItemDecorator -Underscore $_underscore `
      -Index $_index `
      -Exchange $_exchange `
      -Trigger $_trigger
  } # WhItemDecoratorBlock

  HeaderBlock          = [scriptblock] {
    param(
      [hashtable]$Exchange = @{}
    )

    Show-Header -Exchange $Exchange;
  } # HeaderBlock

  SummaryBlock         = [scriptblock] {
    param(
      [int]$Count,
      [int]$Skipped,
      [int]$Errors,
      [boolean]$Triggered,
      [hashtable]$Exchange = @{}
    )

    Show-Summary -Count $Count -Skipped $Skipped `
      -Errors $Errors -Triggered $Triggered -Exchange $Exchange;
  } # SummaryBlock
}

# Session UI state
#
[int]$global:_LineLength = 121;
[int]$global:_SmallLineLength = 81;
#
$global:LoopzUI = [ordered]@{
  # Line definitions:
  #
  UnderscoreLine      = ([string]::new("_", $_LineLength));
  EqualsLine          = ([string]::new("=", $_LineLength));
  DotsLine            = ([string]::new(".", $_LineLength));
  DashLine            = ([string]::new("-", $_LineLength));
  LightDotsLine       = (([string]::new(".", (($_LineLength - 1) / 2))).Replace(".", ". ") + ".");
  LightDashLine       = (([string]::new("-", (($_LineLength - 1) / 2))).Replace("-", "- ") + "-");
  TildeLine           = ([string]::new("~", $_LineLength));

  SmallUnderscoreLine = ([string]::new("_", $_SmallLineLength));
  SmallEqualsLine     = ([string]::new("=", $_SmallLineLength));
  SmallDotsLine       = ([string]::new(".", $_SmallLineLength));
  SmallDashLine       = ([string]::new("-", $_SmallLineLength));
  SmallLightDotsLine  = (([string]::new(".", (($_SmallLineLength - 1) / 2))).Replace(".", ". ") + ".");
  SmallLightDashLine  = (([string]::new("-", (($_SmallLineLength - 1) / 2))).Replace("-", "- ") + "-");
  SmallTildeLine      = ([string]::new("~", $_SmallLineLength));
}

$global:Loopz = [PSCustomObject]@{
  InlineCodeToOption    = [hashtable]@{
    'm' = 'Multiline';
    'i' = 'IgnoreCase';
    'x' = 'IgnorePatternWhitespace';
    's' = 'Singleline';
    'n' = 'ExplicitCapture';
  }

  FsItemTypePlaceholder = '*{_fileSystemItemType}';
  DependencyPlaceholder = '*{_dependency}';

  SignalLabel           = 0;
  SignalEmoji           = 1;

  MissingSignal         = @{
    'windows' = (@('???', '🔻', '?!'));
    'linux'   = (@('???', '🔴', '?!'));
    'mac'     = (@('???', '🔺', '?!'));
  }

  # TODO:
  # - See
  # * https://devblogs.microsoft.com/commandline/windows-command-line-unicode-and-utf-8-output-text-buffer/
  # * https://stackoverflow.com/questions/49476326/displaying-unicode-in-powershell
  #
  DefaultSignals        = [ordered]@{
    # Operations
    #
    'CUT-A'           = (@('Cut', '✂️', ' Σ'));
    'CUT-B'           = (@('Cut', '🦄', ' Σ'));
    'COPY-A'          = (@('Copy', '🍒', ' ©️'));
    'COPY-B'          = (@('Copy', '😺', ' ©️'));
    'MOVE-A'          = (@('Move', '🍺', '≈≈'));
    'MOVE-B'          = (@('Move', '🦊', '≈≈'));
    'PASTE-A'         = (@('Paste', '🌶️', ' ¶'));
    'PASTE-B'         = (@('Paste', '🦆', ' ¶'));
    'OVERWRITE-A'     = (@('Overwrite', '♻️', ' Ø'));
    'OVERWRITE-B'     = (@('Overwrite', '❗', '!!'));
    'PREPEND'         = (@('Prepend', '⏭️', '>|'));
    'APPEND'          = (@('Append', '⏮️', '|<'));

    # Thingies
    #
    'DIRECTORY-A'     = (@('Directory', '📁', 'd>'));
    'DIRECTORY-B'     = (@('Directory', '📂', 'D>'));
    'FILE-A'          = (@('File', '💠', 'f>'));
    'FILE-B'          = (@('File', '📝', 'F>'));
    'PATTERN'         = (@('Pattern', '🛡️', 'p:'));
    'WITH'            = (@('With', '🍑', ' Ψ'));
    'CRUMB-A'         = (@('Crumb', '🎯', '+'));
    'CRUMB-B'         = (@('Crumb', '🧿', '+'));
    'CRUMB-C'         = (@('Crumb', '💎', '+'));
    'SUMMARY-A'       = (@('Summary', '🔆', '*'));
    'SUMMARY-B'       = (@('Summary', '✨', '*'));
    'MESSAGE'         = (@('Message', 'Ⓜ️', '()'));
    'CAPTURE'         = (@('Capture', '☂️', 'λ'));
    'MISSING-CAPTURE' = (@('Missing Capture', '☔', '!λ'));

    # Media
    #
    'AUDIO'           = (@('Audio', '🎶', '_A'));
    'TEXT'            = (@('Text', '🆎', '_T'));
    'DOCUMENT'        = (@('Document', '📜', '_D'));
    'IMAGE'           = (@('Image', '🌌', '_I'));
    'MOVIE'           = (@('Movie', '🎬', '_M'));

    # Indicators
    #
    'WHAT-IF'         = (@('WhatIf', '☑️', '✓'));
    'WARNING-A'       = (@('Warning', '⚠️', ')('));
    'WARNING-B'       = (@('Warning', '👻', ')('));
    'SWITCH-ON'       = (@('On', '✔️', '✓'));
    'SWITCH-OFF'      = (@('Off', '✖️', '×'));
    'INVALID'         = (@('Invalid', '❌', 'XX'));
    'BECAUSE'         = (@('Because', '⚗️', '??'));
    'OK-A'            = (@('OK', '🚀', ':)'));
    'OK-B'            = (@('OK', '🌟', ':D'));
    'BAD-A'           = (@('Bad', '💥', ' ß'));
    'BAD-B'           = (@('Bad', '💢', ':('));
    'PROHIBITED'      = (@('Prohibited', '🚫', ' þ'));
    'INCLUDE'         = (@('Include', '➕', '++'));
    'EXCLUDE'         = (@('Exclude', '➖', '--'));
    'SOURCE'          = (@('Source', '🎀', '+='));
    'DESTINATION'     = (@('Destination', '☀️', '=+'));
    'TRIM'            = (@('Trim', '🌊', '%%'));
    'MULTI-SPACES'    = (@('Spaces', '❄️', '__'));
    'DIAGNOSTICS'     = (@('Diagnostics', '🧪', ' Δ'));
    'LOCKED'          = (@('Locked', '🔐', '>/'));
    'NOVICE'          = (@('Novice', '🔰', ' Ξ'));
    'TRANSFORM'       = (@('Transform', '🤖', ' τ'));
    'BULLET-A'        = (@('Bullet Point', '🔶', '⬥'));
    'BULLET-B'        = (@('Bullet Point', '🟢', '⬡'));
    'BULLET-C'        = (@('Bullet Point', '🟨', '⬠'));
    'BULLET-D'        = (@('Bullet Point', '💠', '⬣'));

    # Outcomes
    #
    'FAILED-A'        = (@('Failed', '☢️', '$!'));
    'FAILED-B'        = (@('Failed', '💩', '$!'));
    'SKIPPED-A'       = (@('Skipped', '💤', 'zz'));
    'SKIPPED-B'       = (@('Skipped', '👾', 'zz'));
    'ABORTED-A'       = (@('Aborted', '✖️', 'o:'));
    'ABORTED-B'       = (@('Aborted', '👽', 'o:'));
    'CLASH'           = (@('Clash', '📛', '>¬'));
    'NOT-ACTIONED'    = (@('Not Actioned', '⛔', '-¬'));

    # Command Specific
    #
    'REMY.ANCHOR'     = (@('Anchor', '⚓', ' §'));
    'REMY.POST'       = (@('Post Process', '🌈', '=>'));
    'REMY.DROP'       = (@('Drop', '💧', ' ╬'));
    'REMY.UNDO'       = (@('Undo Rename', '❎', ' μ'));
    'GREPS'           = (@('greps', '🔍', 'γ'));
  }

  OverrideSignals       = @{ # Label, Emoji
    'windows' = @{
      # defaults based on windows, so there should be no need for overrides
    };

    'linux'   = @{
      # tbd
    };

    'mac'     = @{
      # tbd
    };
  }

  # DefaultSignals resolved into Signals by Initialize-Signals
  #
  Signals               = $null;

  # User defined signals, should be populated by profile
  #
  CustomSignals         = $null;

  SignalRegistry        = @{
    'greps' = @('GREPS');

    'remy'  = @(
      'ABORTED-A', 'APPEND', 'BECAUSE', 'CAPTURE', 'CLASH', 'COPY-A', 'CUT-A', 'DIAGNOSTICS',
      'DIRECTORY-A', 'EXCLUDE', 'FILE-A', 'INCLUDE', 'LOCKED', 'MULTI-SPACES', 'NOT-ACTIONED',
      'NOVICE', 'PASTE-A', 'PATTERN', 'PREPEND', 'REMY.ANCHOR', 'REMY.ANCHOR', 'REMY.DROP',
      'REMY.POST', 'REMY.UNDO', 'TRANSFORM', 'TRIM', 'WHAT-IF', 'WITH'
    );

    'sharp' = @(
      'BULLET-A', 'BULLET-C', 'BULLET-D'
    );

    'ships' = @(
      'BULLET-B', 'SWITCH-ON', 'SWITCH-OFF'
    );

    'shire' = @(
      'FAILED-A', 'INVALID', 'OK-A'
    )
  }

  Defaults              = [PSCustomObject]@{
    Remy = [PSCustomObject]@{
      Marker  = [char]0x2BC1;

      Context = [PSCustomObject]@{
        Title             = 'Rename';
        ItemMessage       = 'Rename Item';
        SummaryMessage    = 'Rename Summary';
        Locked            = 'LOOPZ_REMY_LOCKED';
        UndoDisabledEnVar = 'LOOPZ_REMY_UNDO_DISABLED';
        OperantShortCode  = 'remy';
      }
    }
  }

  Rules                 = [PSCustomObject]@{
    Remy = @(
      @{
        ID             = 'MissingCapture';
        'IsApplicable' = [scriptblock] {
          param([string]$_Input)
          $_Input -match '\$\{\w+\}';
        };

        'Transform'    = [scriptblock] {
          param([string]$_Input)
          $_Input -replace "\$\{\w+\}", ''
        };
        'Signal'       = 'MISSING-CAPTURE'
      },

      @{
        ID             = 'Trim';
        'IsApplicable' = [scriptblock] {
          param([string]$_Input)
          $($_Input.StartsWith(' ') -or $_Input.EndsWith(' '));
        };

        'Transform'    = [scriptblock] {
          param([string]$_Input)
          $_Input.Trim();
        };
        'Signal'       = 'TRIM'
      },

      @{
        ID             = 'Spaces';
        'IsApplicable' = [scriptblock] {
          param([string]$_Input)
          $_Input -match "\s{2,}";
        };

        'Transform'    = [scriptblock] {
          param([string]$_Input)
          $_Input -replace "\s{2,}", ' '
        };
        'Signal'       = 'MULTI-SPACES'
      }
    );
  }

  InvalidCharacterSet   = [char[]]'<>:"/\|?*';
}

function Initialize-ShellOperant {
  <#
  .NAME
    Initialize-ShellOperant
 
  .SYNOPSIS
    Operant factory function.
 
  .DESCRIPTION
    By default all operant related files are stored somewhere inside the home path.
  Actually, a predefined subpath under home is used. This can be customised by the user
  by them defining an alternative path (in the environment as 'LOOPZ_PATH'). This
  alternative path can be relative or absolute. Relative paths are relative to the
  home directory.
    The options specify how the operant is created and must be a PSCustomObject with
  the following fields (examples provided inside brackets relate to Rename-Many command):
  - ShortCode ('remy'): a short string denoting the related command
  - OperantName ('UndoRename'): name of the operant class required
  - Shell ('PoShShell'): The type of shell that the command should be generated for. So
  for PowerShell the user would specify 'PoShShell' (which for the time being is the
  only shell supported).
  - BaseFilename ('undo-rename'): the core part of the file name which should reflect
  the nature of the operant (the operation, which ideally should be a verb noun pair
  but is not enforced)
  - DisabledEnVar ('LOOPZ_REMY_UNDO_DISABLED'): The environment variable used to disable
  this operant.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER DryRun
    Similar to WhatIf, but by passing ShouldProcess process for custom handling of
  dry run scenario. DryRun should be set if WhatIf is enabled.
 
  .PARAMETER HomePath
      User's home directory. (This parameter does not need to be set by client, just
    used for testing purposes.)
 
  .PARAMETER Options
    (See command description for $Options field descriptions).
 
  .EXAMPLE 1
  Operant options for Rename-Many(remy) command
 
  [PSCustomObject]$operantOptions = [PSCustomObject]@{
    ShortCode = 'remy';
    OperantName = 'UndoRename';
    Shell = 'PoShShell';
    BaseFilename = 'undo-rename';
    DisabledEnVar = 'LOOPZ_REMY_UNDO_DISABLED';
  }
 
  #>

  [OutputType([Operant])]
  param(
    [Parameter()]
    [string]$HomePath = $(Resolve-Path "~"),

    [Parameter()]
    [PSCustomObject]$Options,

    [Parameter()]
    [switch]$DryRun
  )
  [string]$envUndoRenameDisabled = $(Get-EnvironmentVariable -Variable $Options.DisabledEnVar);

  try {
    [boolean]$isDisabled = if (-not([string]::IsNullOrEmpty($envUndoRenameDisabled))) {
      [System.Convert]::ToBoolean($envUndoRenameDisabled);
    }
    else {
      $false;
    }
  }
  catch {
    [boolean]$isDisabled = $false;
  }

  [Operant]$operant = if (-not($isDisabled)) {
    [string]$loopzPath = $(Get-EnvironmentVariable -Variable 'LOOPZ_PATH');
    [string]$subPath = ".loopz" + [System.IO.Path]::DirectorySeparatorChar + $($Options.ShortCode);
    if ([string]::IsNullOrEmpty($loopzPath)) {
      $loopzPath = Join-Path -Path $HomePath -ChildPath $subPath;
    }
    else {
      $loopzPath = [System.IO.Path]::IsPathRooted($loopzPath) `
        ? $(Join-Path -Path $loopzPath -ChildPath $subPath) `
        : $(Join-Path -Path $HomePath -ChildPath $loopzPath -AdditionalChildPath $subPath);
    }

    if (-not(Test-Path -Path $loopzPath -PathType Container)) {
      if (-not($DryRun)) {
        $null = New-Item -Type Directory -Path $loopzPath;
      }
    }

    New-ShellOperant -BaseFilename $Options.BaseFilename `
      -Directory $loopzPath -Operant $($Options.OperantName) -Shell $Options.Shell;
  }
  else {
    $null;
  }

  return $operant;
}

function Move-Match {
  <#
  .NAME
    Move-Match
 
  .SYNOPSIS
    The core move match action function principally used by Rename-Many. Moves a
  match according to the specified anchor(s).
 
  .DESCRIPTION
    Returns a new string that reflects moving the specified $Pattern match to either
  the location designated by $Anchor/$AnchorOccurrence/$Relation or to the Start or
  End of the value indicated by the presence of the $Start/$End switch parameters.
    First Move-Match, removes the Pattern match from the source. This makes the With and
  Anchor match against the remainder ($patternRemoved) of the source. This way, there is
  no overlap between the Pattern match and With/Anchor and it also makes the functionality more
  understandable for the user. NB: $Pattern only tells you what to remove, but it's the
  $With, $Copy and $Paste that defines what to insert, with the $Anchor/$Start/$End
  defining where the replacement text should go. The user should not be using named capture
  groups in $Copy, or $Anchor, rather, they should be defined inside $Paste and referenced
  inside $Paste/$With.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Anchor
    Anchor is a regular expression string applied to $Value (after the $Pattern match has
  been removed). The $Pattern match that is removed is inserted at the position indicated
  by the anchor match in collaboration with the $Relation parameter.
 
  .PARAMETER AnchorOccurrence
    Can be a number or the letters f, l
  * f: first occurrence
  * l: last occurrence
  * <number>: the nth occurrence
 
  .PARAMETER Copy
    Regular expression object applied to $Value (after the $Pattern match has been removed),
  indicating a portion which should be copied and re-inserted (via the $Paste parameter;
  see $Paste or $With). Since this is a regular expression to be used in $Paste/$With, there
  is no value in the user specifying a static pattern, because that static string can just be
  defined in $Paste/$With. The value in the $Copy parameter comes when a generic pattern is
  defined eg \d{3} (is non static), specifies any 3 digits as opposed to say '123', which
  could be used directly in the $Paste/$With parameter without the need for $Copy. The match
  defined by $Copy is stored in special variable ${_c} and can be referenced as such from
  $Paste and $With.
 
  .PARAMETER CopyOccurrence
    Can be a number or the letters f, l
  * f: first occurrence
  * l: last occurrence
  * <number>: the nth occurrence
 
  .PARAMETER Cut
    Regular expression object that indicates which part of the $Value that
  either needs removed as part of overall rename operation. Those characters
  in $Value which match $Cut, are removed.
 
  .PARAMETER CutOccurrence
    Can be a number or the letters f, l
  * f: first occurrence
  * l: last occurrence
  * <number>: the nth occurrence
 
  .PARAMETER Diagnose
    switch parameter that indicates the command should be run in WhatIf mode. When enabled
  it presents additional information that assists the user in correcting the un-expected
  results caused by an incorrect/un-intended regular expression. The current diagnosis
  will show the contents of named capture groups that they may have specified. When an item
  is not renamed (usually because of an incorrect regular expression), the user can use the
  diagnostics along side the 'Not Renamed' reason to track down errors. When $Diagnose has
  been specified, $WhatIf does not need to be specified.
 
  .PARAMETER Drop
    A string parameter (only applicable to move operations, ie any of these Anchor/Star/End
  are present) that defines what text is used to replace the $Pattern match. So in this
  use-case, the user wants to move a particular token/pattern to another part of the name
  and at the same time drop a static string in the place where the $Pattern was removed from.
  The user can also reference named group captures defined inside Pattern or Copy. (Note that
  the whole Copy capture can be referenced with ${_c}.)
 
  .PARAMETER End
    Is another type of anchor used instead of $Anchor and specifies that the $Pattern match
  should be moved to the end of the new name.
 
  .PARAMETER Marker
    A character used to mark the place where the $Pattern was removed from. It should be a
  special character that is not easily typed on the keyboard by the user so as to not
  interfere wth $Anchor/$Copy matches which occur after $Pattern match is removed.
 
  .PARAMETER Pattern
    Regular expression object that indicates which part of the $Value that
  either needs to be moved or replaced as part of overall rename operation. Those characters
  in $Value which match $Pattern, are removed.
 
  .PARAMETER PatternOccurrence
    Can be a number or the letters f, l
  * f: first occurrence
  * l: last occurrence
  * <number>: the nth occurrence
 
  .PARAMETER Relation
    Used in conjunction with the $Anchor parameter and can be set to either 'before' or
  'after' (the default). Defines the relationship of the $Pattern match with the $Anchor
  match in the new name for $Value.
 
  .PARAMETER Start
    Is another type of anchor used instead of $Anchor and specifies that the $Pattern match
  should be moved to the start of the new name.
 
  .PARAMETER Value
    The source value against which regular expressions are applied.
 
  .PARAMETER With
    This is a NON regular expression string. It would be more accurately described as a formatter.
  Defines what text is used as the replacement for the $Pattern
  match. $With can reference special variables:
  * $0: the pattern match
  * ${_a}: the anchor match
  * ${_c}: the copy match
  When $Pattern contains named capture groups, these variables can also be referenced. Eg if the
  $Pattern is defined as '(?<day>\d{1,2})-(?<mon>\d{1,2})-(?<year>\d{4})', then the variables
  ${day}, ${mon} and ${year} also become available for use in $With.
  Typically, $With is static text which is used to replace the $Pattern match and is inserted
  according to the Anchor match, (or indeed $Start or $End).
 
  .EXAMPLE 1 (Anchor)
  Move-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Anchor ' -' -Relation 'before'
 
  Move a match before an anchor
 
  .EXAMPLE 2 (Anchor)
  Move-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Anchor ' -' -Relation 'before' -Drop '-'
 
  Move a match before an anchor and drop a literal in place of Pattern
 
  .EXAMPLE 3 (Hybrid Anchor)
  Move-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Anchor ' -' -End -Relation 'before' -Drop '-'
 
  Move a match before an anchor, if anchor match fails, then move to end, then drop a literal in place of Pattern.
 
  .EXAMPLE 4 (Anchor with Occurrence)
  Move-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Anchor ' -' -Relation 'before' -AnchorOccurrence 'l'
 
  .EXAMPLE 5 (Result formatted by With, named group reference)
  Move-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Anchor ' -' -With '--(${dt})--'
 
  Move a match to the anchor, and format the output including group references, no anchor
 
  .EXAMPLE 6 (Result formatted by With, named group reference and insert anchor)
  Move-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Anchor ' -' -With '${_a}, --(${dt})--'
 
  Move a match to the anchor, and format the output including group references, insert anchor
  #>


  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSPossibleIncorrectUsageOfAssignmentOperator', '')]
  [Alias('moma')]
  [OutputType([string])]
  param (
    [Parameter(Mandatory, Position = 0)]
    [string]$Value,

    [Parameter(ParameterSetName = 'HybridStart', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'HybridEnd', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'MoveToAnchor', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'MoveToStart', Mandatory, Position = 1)]
    [Parameter(ParameterSetName = 'MoveToEnd', Mandatory, Position = 1)]
    [System.Text.RegularExpressions.RegEx]$Pattern,

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [Parameter(ParameterSetName = 'MoveToStart')]
    [Parameter(ParameterSetName = 'MoveToEnd')]
    [string]$PatternOccurrence = 'f',

    [Parameter(ParameterSetName = 'NoReplacement', Mandatory)]
    [System.Text.RegularExpressions.RegEx]$Cut,

    [Parameter(ParameterSetName = 'NoReplacement')]
    [string]$CutOccurrence = 'f',

    [Parameter(ParameterSetName = 'HybridStart', Mandatory)]
    [Parameter(ParameterSetName = 'HybridEnd', Mandatory)]
    [Parameter(ParameterSetName = 'MoveToAnchor', Mandatory)]
    [System.Text.RegularExpressions.RegEx]$Anchor,

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [string]$AnchorOccurrence = 'f',

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [ValidateSet('before', 'after')]
    [string]$Relation = 'after',

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [Parameter(ParameterSetName = 'MoveToStart')]
    [Parameter(ParameterSetName = 'MoveToEnd')]
    [System.Text.RegularExpressions.RegEx]$Copy,

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [Parameter(ParameterSetName = 'MoveToStart')]
    [Parameter(ParameterSetName = 'MoveToEnd')]
    [string]$CopyOccurrence = 'f',

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [Parameter(ParameterSetName = 'MoveToStart')]
    [Parameter(ParameterSetName = 'MoveToEnd')]
    [string]$With,

    [Parameter(ParameterSetName = 'HybridStart', Mandatory)]
    [Parameter(ParameterSetName = 'MoveToStart', Mandatory)]
    [switch]$Start,

    [Parameter(ParameterSetName = 'HybridEnd', Mandatory)]
    [Parameter(ParameterSetName = 'MoveToEnd', Mandatory)]
    [switch]$End,

    [Parameter(ParameterSetName = 'HybridStart')]
    [Parameter(ParameterSetName = 'HybridEnd')]
    [Parameter(ParameterSetName = 'MoveToAnchor')]
    [Parameter(ParameterSetName = 'MoveToStart')]
    [Parameter(ParameterSetName = 'MoveToEnd')]
    [string]$Drop,

    [Parameter()]
    [switch]$Diagnose,

    [Parameter()]
    [char]$Marker = 0x20DE
  )

  [string]$result = [string]::Empty;
  [string]$failedReason = [string]::Empty;
  [hashtable]$notes = [hashtable]@{}
  [PSCustomObject]$diagnostics = [PSCustomObject]@{
    Named = @{}
  }

  [boolean]$isAnchored = $PSBoundParameters.ContainsKey('Anchor') -and -not([string]::IsNullOrEmpty($Anchor));
  [boolean]$isFormattedWith = $PSBoundParameters.ContainsKey('With') -and -not([string]::IsNullOrEmpty($With));
  [boolean]$dropped = $PSBoundParameters.ContainsKey('Drop') -and -not([string]::IsNullOrEmpty($Drop));
  [boolean]$isPattern = $PSBoundParameters.ContainsKey('Pattern') -and -not([string]::IsNullOrEmpty($Pattern));
  [boolean]$isCut = $PSCmdlet.ParameterSetName -eq 'NoReplacement';
  [boolean]$isVanillaMove = -not($PSBoundParameters.ContainsKey('With')) -and -not($isCut);
  [boolean]$isCopy = $PSBoundParameters.ContainsKey('Copy');
  
  [Hashtable]$patternCaptures = @{}
  [Hashtable]$copyCaptures = @{}
  [Hashtable]$anchorCaptures = @{}

  # Determine what to remove
  #
  [string]$removalText, $remainingText = if ($isPattern) {
    [hashtable]$parameters = @{
      'Source'       = $Value;
      'PatternRegEx' = $Pattern;
      'Occurrence'   = $($PSBoundParameters.ContainsKey('PatternOccurrence') ? $PatternOccurrence : 'f');
    }

    if ($dropped) {
      $parameters['Marker'] = $Marker;
    }

    [string]$capturedPattern, [string]$patternRemoved, `
      [System.Text.RegularExpressions.Match]$patternMatch = Split-Match @parameters;

    if (-not([string]::IsNullOrEmpty($capturedPattern))) {
      [hashtable]$patternCaptures = get-Captures -MatchObject $patternMatch;
      if ($Diagnose.ToBool()) {
        $diagnostics.Named['Pattern'] = $patternCaptures;
      }
    }
    else {
      # Source doesn't match Pattern
      #
      $failedReason = 'Pattern Match';
    }

    $capturedPattern, $patternRemoved;
  }
  elseif ($isCut) {
    [hashtable]$parameters = @{
      'Source'       = $Value;
      'PatternRegEx' = $Cut;
      'Occurrence'   = $($PSBoundParameters.ContainsKey('CutOccurrence') ? $CutOccurrence : 'f');
    }

    [string]$capturedCut, [string]$cutRemoved, `
      [System.Text.RegularExpressions.Match]$cutMatch = Split-Match @parameters;

    if (-not([string]::IsNullOrEmpty($capturedCut))) {
      $result = $cutRemoved;
    }
    else {
      # Source doesn't match Cut
      #
      $failedReason = 'Cut Match';
    }

    $capturedCut, $cutRemoved;
  }

  # Determine the replacement text (Copy/With)
  #
  if ([string]::IsNullOrEmpty($failedReason)) {
    if ([string]::IsNullOrEmpty($result)) {
      # The replaceWith here takes into account pattern ($0) and copy (${_c}) references inside the With
      # and also any of the user defined named group/capture no references in With, regardless of $isPattern,
      # $isCut, $Anchor, $Start or $End; ie it's universal.
      #
      [string]$replaceWith = if ($isVanillaMove) {
        # Insert the original pattern match, because there is no Copy/With.
        # If there is a Copy, without a With, the Copy is ignored, its only processed if
        # doing an exotic move (the opposite from $isVanilla)
        #
        $capturedPattern;
      }
      else {
        # Determine what to Copy
        #
        [string]$copyText = if ($isCopy) {
          [hashtable]$parameters = @{
            'Source'       = $remainingText
            'PatternRegEx' = $Copy
            'Occurrence'   = $($PSBoundParameters.ContainsKey('CopyOccurrence') ? $CopyOccurrence : 'f')
          }

          # With this implementation, it is up to the user to supply a regex proof
          # pattern, so if the Copy contains regex chars which must be treated literally, they
          # must pass in the string pre-escaped: -Copy $(esc('some-pattern') + 'other stuff').
          #
          [string]$capturedCopy, $null, `
            [System.Text.RegularExpressions.Match]$copyMatch = Split-Match @parameters;

          if (-not([string]::IsNullOrEmpty($capturedCopy))) {
            [hashtable]$copyCaptures = get-Captures -MatchObject $copyMatch;
            if ($Diagnose.ToBool()) {
              $diagnostics.Named['Copy'] = $copyCaptures;
            }
            $capturedCopy;
          }
          else {
            # Copy doesn't match so abort
            #
            $failedReason = 'Copy Match';
            [string]::Empty;
          }
        } # $copyText =
        else {
          [System.Text.RegularExpressions.Match]$copyMatch = $null;
          [string]::Empty;
        }

        # Determine With
        #
        if ([string]::IsNullOrEmpty($failedReason)) {
          # With can be something like '___ ${_a}, (${x}, ${y}, [$0], ${_c} ___', where $0
          # represents the pattern capture, the special variable _c represents $Copy,
          # _a represents the anchor and ${x} and ${y} represents user defined capture groups.
          # The With replaces the anchor, so to re-insert the anchor _a, it must be referenced
          # in the With format. Numeric captures may also be referenced.
          #

          # Handle whole Copy/Pattern reference inside the replacement text (With)
          #
          [string]$tempWith = $With.Replace('${_c}', $copyText).Replace('$0', $capturedPattern);

          # Handle Pattern group references
          #
          $tempWith = Update-GroupRefs -Source $tempWith -Captures $patternCaptures;

          # Handle Copy group references
          #
          $tempWith = Update-GroupRefs -Source $tempWith -Captures $copyCaptures;

          $tempWith;
        }
        else {
          [string]::Empty;
        }
      } # = replaceWith
    } # $result
  } # $failedReason
  else {
    [string]$replaceWith = [string]::Empty;
  }

  # Where to move match to
  #
  [string]$capturedAnchor = [string]::Empty;

  if ([string]::IsNullOrEmpty($failedReason)) {
    if ([string]::IsNullOrEmpty($result)) {
      # The anchor is the final replacement. The anchor match can contain group
      # references. The anchor is replaced by With (replacement text) and it's the With
      # that can contain Copy/Pattern/Anchor group references. If there is no Start/End/Anchor
      # then the move operation is a Cut which is handled separately.
      #
      # Here we need to apply exotic move to Start/End as well as Anchor => move-Exotic
      # The only difference between these 3 scenarios is the destination
      #
      if ($isAnchored) {
        [hashtable]$parameters = @{
          'Source'       = $remainingText;
          'PatternRegEx' = $Anchor;
          'Occurrence'   = $($PSBoundParameters.ContainsKey('AnchorOccurrence') ? $AnchorOccurrence : 'f');
        }

        # As with the Copy parameter, if the user wants to specify an anchor by a pattern
        # which contains regex chars, then can use -Anchor $(esc('anchor-pattern')). If
        # there are no regex chars, then they can use -Anchor 'pattern'. However, if the
        # user needs to do partial escapes, then they will have to do the escaping
        # themselves: -Anchor $(esc('partial-pattern') + 'remaining-pattern').
        #
        [string]$capturedAnchor, $null, `
          [System.Text.RegularExpressions.Match]$anchorMatch = Split-Match @parameters;

        if (-not([string]::IsNullOrEmpty($capturedAnchor))) {
          [string]$format = if ($isFormattedWith) {
            $replaceWith;
          }
          else {
            ($Relation -eq 'before') ? $replaceWith + $capturedAnchor : $capturedAnchor + $replaceWith;
          }

          # Resolve anchor reference inside With
          #
          $format = $format.Replace('${_a}', $capturedAnchor);

          # This resolves the Anchor named group references ...
          #
          $result = $Anchor.Replace($remainingText, $format, 1, $anchorMatch.Index);

          # ... but we still need to capture the named groups for diagnostics
          #
          $anchorCaptures = get-Captures -MatchObject $anchorMatch;
          if ($Diagnose.ToBool()) {
            $diagnostics.Named['Anchor'] = $anchorCaptures;
          }
        }
        else {
          # Anchor doesn't match Pattern
          #
          if ($Start.ToBool()) {
            $result = $replaceWith + $remainingText;
            $notes['hybrid'] = 'Start (Anchor failed)';
          }
          elseif ($End.ToBool()) {
            $result = $remainingText + $replaceWith;
          }
          else {
            $failedReason = 'Anchor Match';
            $notes['hybrid'] = 'End (Anchor failed)';
          }
        }
      }
      elseif ($Start.ToBool()) {
        $result = $replaceWith + $remainingText;
      }
      elseif ($End.ToBool()) {
        $result = $remainingText + $replaceWith;
      }
      elseif ($isCut) {
        $result = $remainingText;
      }
    } # $result
  }  # $failedReason

  # Perform Drop
  #
  if ([string]::IsNullOrEmpty($failedReason)) {
    if ($isPattern) {
      [string]$dropText = $Drop;

      # Now cross reference the Pattern group references
      #
      if ($patternCaptures.PSBase.Count -gt 0) {
        $dropText = Update-GroupRefs -Source $dropText -Captures $patternCaptures;
      }

      # Now cross reference the Copy group references
      #
      if ($isCopy -and ($copyCaptures.PSBase.Count -gt 0)) {
        $dropText = $dropText.Replace('${_c}', $copyCaptures['0']);

        $dropText = Update-GroupRefs -Source $dropText -Captures $copyCaptures;
      }

      # Now cross reference the Anchor group references
      #
      if (-not([string]::IsNullOrEmpty($capturedAnchor))) {
        $dropText = $dropText.Replace('${_a}', $capturedAnchor);
      }

      if ($anchorCaptures.PSBase.Count -gt 0) {
        $dropText = Update-GroupRefs -Source $dropText -Captures $anchorCaptures;
      }

      $result = $result.Replace([string]$Marker, $dropText);
    }
  } # $failedReason

  [boolean]$success = [string]::IsNullOrEmpty($failedReason);
  [PSCustomObject]$moveResult = [PSCustomObject]@{
    Payload         = $success ? $result : $Value;
    Success         = $success;
    CapturedPattern = $isPattern ? $capturedPattern : $($isCut ? $capturedCut : [string]::Empty);
  }

  if (-not([string]::IsNullOrEmpty($failedReason))) {
    $moveResult | Add-Member -MemberType NoteProperty -Name 'FailedReason' -Value $failedReason;
  }

  if ($Diagnose.ToBool() -and ($diagnostics.Named.Count -gt 0)) {
    $moveResult | Add-Member -MemberType NoteProperty -Name 'Diagnostics' -Value $diagnostics;
  }

  if ($notes.PSBase.Count -gt 0) {
    $moveResult | Add-Member -MemberType NoteProperty -Name 'Notes' -Value $notes;
  }

  return $moveResult;
}

function New-BootStrap {
  <#
  .NAME
    New-BootStrap
 
  .SYNOPSIS
    Bootstrap factory function
 
  .DESCRIPTION
  
  Creates a bootstrap instance for the client command. When a command designed to show a
  lot of output and indication signals, the bootstrap class can be used to help manage this
  complexity in a common way.
   
  A command may want to show the presence of user
  defined parameters with Signals. By using the Boot-strapper the client can be designed
  without having to implement the logic of showing indicators. All the client needs
  to do is to define a 'spec' object which describes a parameter or other indicator
  and then register this spec with the Boot-strapper. The Boot-strapper then creates an
  'Entity' that relates to the spec.
 
  There are 2 types of entity, primary and related. Primary entities should have a
  boolean Activate property. This denotes whether the entity is created, actioned
  and stored in the bootstrap. Relation entities are dependent on either other
  primary or related entities. Instead of a boolean Activate property, they should
  have an Activator predicate property which is a script block that returns a boolean.
  Typically, the Activator determines it's activated state by consulting other
  entities, returning true if it is active, false otherwise.
 
    Entities are used to tie together various pieces of information into a single bundle.
  This ensures that for a particular item the logic and info is centralised and handled
  in a consistent manner. The various concepts that are handled by an entity are:
 
  * handle items that needs some kind of transformation (eg, regex entities need to be
  constructed via New-RegularExpression)
  * populating exchange
  * creation of signal
  * formulation and validation of formatters
  * container selection
 
  There are currently four entity types:
 
  + **SimpleEntity**: Simple item that does not need any transformation of the value and is not
  represented by a signal. A simple entity can be used if all that is required
  is to populate an exchange entry (via Keys); this is why the Value member is
  optional.
 
  Spec properties:
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Name (mandatory) -> identifies the entity
  * SpecType (mandatory) -> 'simple'
  * Value (optional) -> typically the value of a parameter, but can be anything.
  * Keys (optional) -> Collection of key/value pairs to be inserted into exchange.
 
  + **SignalEntity**: For signalled entities. (eg a parameter that is associated with a signal)
 
    Spec properties:
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Name (mandatory) -> identifies the entity
  * SpecType (mandatory) -> 'signal'
  * Value (optional) -> the primary value for this entity (not necessarily the display value)
  * Signal (mandatory) -> name of the signal
  * SignalValue (optional) -> the display value of the signal
  * Force (optional) -> container selector.
  * Keys (optional) -> Collection of key/value pairs to be inserted into exchange.
 
  + **RegexEntity**: For regular expressions. Used to create a regex entity. The entity can
  represent either a parameter or an independent regex.
 
    A derived regex entity can be created which references another regex. The derived
  value must reference the dependency by including the static place holder string
  '*{_dependency}'.
 
  Spec properties:
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Name (mandatory) -> identifies the entity
  * Value (optional) -> the value of the user supplied expression (including occurrence)
  * Signal (optional) -> should be provided for parameters, optional for non parameters
  * WholeSpecifier (optional) -> single letter code identifying this regex parameter.
  * Force (optional) -> container selector.
  * RegExKey (optional) -> Key identifying where the internally created [regex] object
    is stored in exchange.
  * OccurrenceKey (optional) -> Key identifying where the occurrence value for this regex
    is stored in exchange.
  * Keys (optional) -> Collection of key/value pairs to be inserted into exchange.
 
  For derived:
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Dependency (mandatory) -> Name of required regex entity
  * Name (mandatory)
  * SpecType (mandatory) -> 'regex'
  * Value -> The pattern which should include placeholder '*{_dependency}'
  * RegExKey (optional)
  * OccurrenceKey (optional)
   
  + **FormatterEntity**: For formatter parameters, which formats a file or directory name.
    This is a signal entity with the addition of a validator which checks that the
  value represented does not contain file system unsafe characters. Uses function
  Test-IsFileSystemSafe to perform this check.
 
  Spec properties:
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Name (mandatory) -> identifies the entity
  * SpecType (mandatory) -> 'formatter'
  * Value (optional) -> the value of the user supplied expression (including occurrence)
  * Signal (optional) -> should be provided for parameters, optional for non parameters
  * WholeSpecifier (optional) -> single letter code identifying this regex parameter.
  * Force (optional) -> container selector.
  * Keys (optional) -> Collection of key/value pairs to be inserted into exchange.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Containers
    A PSCustomObject that must contain a 'Wide' property and a 'Props' property. Both of
  these must be of type Krayola.Line. 'Prop's are designed to show a small item of information,
  typically 5/6 characters long; multiple props would typically easily fit on a single line
  in the console. Wide items, are those which when show take up a lot of screen space, eg
  showing a file's full path is often a 'wide' item, so it would be best to present it on
  its own line.
 
  .PARAMETER Exchange
    The exchange instance to populate.
 
  .PARAMETER Options
    Not mandatory. Currently, only specifies a 'Whole' property. The 'Whole' property is a
  string containing multiple individual characters each one maps to a regex parameter and
  indicates if that regex pattern should be applied as a whole; ie it is wrapped up in
  the word boundary token '\b' to indicate that it should match on whole word basis only.
 
  #>

  [OutputType([BootStrap])]
  param(
    [Parameter()]
    [hashtable]$Exchange,

    [Parameter()]
    [PSCustomObject]$Containers,

    [Parameter()]
    [PSCustomObject]$Options = $([PSCustomObject]@{})
  )

  return [BootStrap]::new($Exchange, $Containers, $Options);
}

function New-RegularExpression {
  <#
  .NAME
    New-RegularExpression
 
  .SYNOPSIS
    regex factory function.
 
  .DESCRIPTION
    Creates a regex object from the $Expression specified. Supports inline regex
  flags ('mixsn') which must be specified at the end of the $Expression after a
  '/'.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Escape
    switch parameter to indicate that the expression should be escaped. (This is an
  alternative to the '~' prefix).
 
  .PARAMETER Expression
    The pattern for the regular expression. If it starts with a tilde ('~'), then
  the whole expression is escaped so any special regex characters are interpreted
  literally.
 
  .PARAMETER Label
    string that gives a name to the regular expression being created and is used for
  logging/error reporting purposes only, so it's not mandatory.
 
  .PARAMETER WholeWord
    switch parameter to indicate the expression should be wrapped with word boundary
  markers \b, so an $Expression defined as 'foo' would be adjusted to '\bfoo\b'.
 
  .EXAMPLE 1 (Create a regular expression object)
  New-RegularExpression -Expression '(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})'
 
  .EXAMPLE 2 (with WholeWord)
  New-RegularExpression -Expression '(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})' -WholeWord
 
  .EXAMPLE 3 (Escaped)
  New-RegularExpression -Expression '(123)' -Escape
 
  .EXAMPLE 4 (Escaped with leading ~)
  New-RegularExpression -Expression '~(123)'
 
  .EXAMPLE 5 (Create a case insensitive expression)
  New-RegularExpression -Expression 'DATE/i'
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions',
    '', Justification = 'Not a state changing function, its a factory')]
  [OutputType([System.Text.RegularExpressions.RegEx])]
  param(
    [Parameter(Position = 0, Mandatory)]
    [string]$Expression,

    [Parameter()]
    [switch]$Escape,

    [Parameter()]
    [switch]$WholeWord,

    [Parameter()]
    [string]$Label
  )

  [System.Text.RegularExpressions.RegEx]$resultRegEx = $null;
  [System.Text.RegularExpressions.RegEx]$extractOptionsRegEx = New-Object `
    -TypeName System.Text.RegularExpressions.RegEx -ArgumentList (
    '[\/\\](?<codes>[mixsn]{1,5})$');

  try {
    [string]$adjustedExpression = ($Expression.StartsWith('~') -or $Escape.IsPresent) `
      ? [regex]::Escape($Expression.Substring(1)) : $Expression;

    [string[]]$optionsArray = @();

    [string]$options = if ($extractOptionsRegEx.IsMatch($adjustedExpression)) {
      $null, $adjustedExpression, [System.Text.RegularExpressions.Match]$optionsMatch = `
        Split-Match -Source $adjustedExpression -PatternRegEx $extractOptionsRegEx;

      [string]$inlineCodes = $optionsMatch.Groups['codes'];

      # NOTE, beware of [string]::ToCharArray, the returned result MUST be cast to [string[]]
      #
      [string[]]$inlineCodes.ToCharArray() | ForEach-Object {
        $optionsArray += $Loopz.InlineCodeToOption[$_]
      }

      $optionsArray -join ', ';
    } else {
      $null;
    }

    if (-not([string]::IsNullOrEmpty($options))) {
      Write-Debug "New-RegularExpression; created RegEx for pattern: '$adjustedExpression', with options: '$options'";
    }

    if ($WholeWord.ToBool()) {
      $adjustedExpression = '\b{0}\b' -f $adjustedExpression;
    }

    $arguments = $options ? @($adjustedExpression, $options) : @(, $adjustedExpression);
    $resultRegEx = New-Object -TypeName System.Text.RegularExpressions.RegEx -ArgumentList (
      $arguments);
  }
  catch [System.Management.Automation.MethodInvocationException] {
    [string]$message = ($PSBoundParameters.ContainsKey('Label')) `
      ? $('Regular expression ({0}) "{1}" is not valid, ... terminating ({2}).' `
        -f $Label, $adjustedExpression, $_.Exception.Message)
    : $('Regular expression "{0}" is not valid, ... terminating ({1}).' `
        -f $adjustedExpression, $_.Exception.Message);
    Write-Error -Message $message -ErrorAction Stop;
  }

  $resultRegEx;
}

function Resolve-PatternOccurrence {
  <#
  .NAME
    Resolve-PatternOccurrence
 
  .SYNOPSIS
    Helper function to assist in processing regular expression parameters that can
  be adorned with an occurrence value.
 
  .DESCRIPTION
    Since the occurrence part is optional and defaults to mean first occurrence only,
  this function will fill in the default 'f' when occurrence is not specified.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Value
    The value of a regex parameter, which is an array whose first element is the
  pattern and the second if present is the match occurrence.
 
  #>

  param (
    [Parameter(Position = 0)]
    [array]$Value
  )

  $Value[0], $(($Value.Length -eq 1) ? 'f' : $Value[1]);
} # resolve-PatternOccurrence

function Select-FsItem {
  <#
  .NAME
    Select-FsItem
 
  .SYNOPSIS
    A predicate function that indicates whether an item identified by the Name matches
  the include/exclude filters specified.
 
  .DESCRIPTION
    Use this utility function to help specify a Condition for Invoke-TraverseDirectory.
  This function is partly required because the Include/Exclude parameters on functions
  such as Get-ChildItems/Copy-Item/Get-Item etc only work on files not directories.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Case
    Switch parameter which controls case sensitivity of inclusion/exclusion. By default
  filtering is case insensitive. When The Case switch is specified, filtering is case
  sensitive.
 
  .PARAMETER Excludes
    An array containing a list of filters, each must contain a wild-card ('*'). If a
  particular filter does not contain a wild-card, then it will be ignored. If the Name
  matches any of the filters in the list, will cause the end result to be false.
  Any match in the Excludes overrides a match in Includes, so an item
  that is matched in Include, can be excluded by the Exclude.
 
  .PARAMETER Includes
      An array containing a list of filters, each must contain a wild-card ('*'). If a
  particular filter does not contain a wild-card, then it will be ignored. If Name matches
  any of the filters in Includes, and are not Excluded, the result will be true.
 
  .PARAMETER Name
    A string to be matched against the filters.
 
  .EXAMPLE 1
    Define a Condition that allows only directories beginning with A, but also excludes
    any directory containing '_' or '-'.
 
    [scriptblock]$filterDirectories = {
      [OutputType([boolean])]
      param(
        [System.IO.DirectoryInfo]$directoryInfo
      )
      [string[]]$directoryIncludes = @('A*');
      [string[]]$directoryExcludes = @('*_*', '*-*');
 
      $filterDirectories = Select-FsItem -Name $directoryInfo.Name `
        -Includes $directoryIncludes -Excludes $directoryExcludes;
 
      Invoke-TraverseDirectory -Path <path> -Block <block> -Condition $filterDirectories;
    }
  #>


  [OutputType([boolean])]
  param(
    [Parameter(Mandatory)]
    [string]$Name,

    [Parameter()]
    [string[]]$Includes = @(),

    [Parameter()]
    [string[]]$Excludes = @(),

    [Parameter()]
    [switch]$Case
  )

  # Note we wrap the result inside @() array designator just in-case the where-object
  # returns just a single item in which case the array would be flattened out into
  # an individual scalar value which is what we don't want, damn you powershell for
  # doing this and making life just so much more difficult. Actually, on further
  # investigation, we don't need to wrap inside @(), because we've explicitly defined
  # the type of the includes variables to be arrays, which would preserve the type
  # even in the face of powershell annoyingly flattening the single item array. @()
  # being left in for clarity and show of intent.
  #
  [string[]]$validIncludes = @($Includes | Where-Object { $_.Contains('*') })
  [string[]]$validExcludes = @($Excludes | Where-Object { $_.Contains('*') })

  [boolean]$resolvedInclude = $validIncludes `
    ? (select-ResolvedFsItem -FsItem $Name -Filter $Includes -Case:$Case) `
    : $false;

  [boolean]$resolvedExclude = $validExcludes `
    ? (select-ResolvedFsItem -FsItem $Name -Filter $Excludes -Case:$Case) `
    : $false;

  ($resolvedInclude) -and -not($resolvedExclude)
} # Select-FsItem

function Select-SignalContainer {
  <#
  .NAME
    Select-SignalContainer
 
  .SYNOPSIS
    Selects a signal into the container specified (either 'Wide' or 'Props').
  Wide items will appear on their own line, Props are for items which are
  short in length and can be combined into the same line.
 
  .DESCRIPTION
    This is a wrapper around Get-FormattedSignal in addition to selecting the
  signal into a container.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Containers
    PSCustomObject that contains Wide and Props properties which must be of Krayola's
  type [line].
 
  .PARAMETER CustomLabel
    A custom label applied to the formatted signal.
 
  .PARAMETER Force
    An override (bypassing $Threshold) to push a signal into a specific collection.
 
  .PARAMETER Format
    The format applied to the formatted signal.
 
  .PARAMETER Name
    The signal name.
 
  .PARAMETER Signals
    The signal hashtable collection from which to select the required signal denoted by
  $Name.
 
  .PARAMETER Threshold
    A threshold that defines whether the signal is added to Wide or Props.
 
  .PARAMETER Value
    The value associated wih the signal.
 
  #>

  param(
    [Parameter(Mandatory)]
    [PSCustomObject]$Containers,

    [Parameter(Mandatory)]
    [string]$Name,

    [Parameter(Mandatory)]
    [string]$Value,

    [Parameter()]
    [hashtable]$Signals = $(Get-Signals),

    [Parameter()]
    [string]$Format = '[{1}] {0}', # 0=Label, 1=Emoji,

    [Parameter()]
    [int]$Threshold = 6,

    [Parameter()]
    [string]$CustomLabel,

    [Parameter()]
    [ValidateSet('Wide', 'Props')]
    [string]$Force
  )

  [couplet]$formattedSignal = Get-FormattedSignal -Name $Name -Format $Format -Value $Value `
    -Signals $Signals -CustomLabel $CustomLabel;

  if ($PSBoundParameters.ContainsKey('Force')) {
    if ($Force -eq 'Wide') {
      $null = $Containers.Wide.append($formattedSignal);
    }
    else {
      $null = $Containers.Props.append($formattedSignal);
    }
  }
  else {
    if ($Value.Length -gt $Threshold) {
      $null = $Containers.Wide.append($formattedSignal);
    }
    else {
      $null = $Containers.Props.append($formattedSignal);
    }
  }
}

function Split-Match {
  <#
  .NAME
    Split-Match
 
  .SYNOPSIS
    Splits out a match from the remainder of the $Source text returning the matched
  test, the remainder and the corresponding match object.
 
  .DESCRIPTION
    Helper function to get the pattern match and the remaining text. This helper
  helps us to avoid unnecessary duplicated reg ex matches. It returns
  up to 3 items inside an array, the first is the matched text, the second is
  the source with the matched text removed and the third is the match object
  that represents the matched text.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER CapturedOnly
    switch parameter to indicate what should be returned. When the client does not need
  the match object or the remainder, they can use this switch to ensure only the matched
  text is returned.
 
  .PARAMETER Marker
    A character used to mark the place where the $PatternRegEx's match was removed from.
  It should be a special character that is not easily typed on the keyboard by the user
  so as to not interfere wth $Anchor/$Copy matches which occur after $Pattern match is
  removed.
 
  .PARAMETER Occurrence
    Denotes which match should be used.
 
  .PARAMETER PatternRegEx
    The regex object to apply to the $Source.
 
  .PARAMETER Source
    The source value against which regular expression is applied.
 
  .EXAMPLE 1 (Return full match info)
  [regex]$re = New-RegularExpression -Expression '(?<d>\d{2})-(?<m>\d{2})-(?<y>\d{4})'
  [string]$captured, [string]$remainder, $matchInfo = Split-Match -Source '23-06-2006 - Ex' -PatternRegEx $re
 
  .EXAMPLE 2 (Return captured text only)
  [regex]$re = New-RegularExpression -Expression '(?<d>\d{2})-(?<m>\d{2})-(?<y>\d{4})'
  [string]$captured = Split-Match -Source '23-06-2006 - Ex' -PatternRegEx $re -CapturedOnly
  #>

  param(
    [Parameter(Mandatory)]
    [string]$Source,

    [Parameter()]
    [System.Text.RegularExpressions.RegEx]$PatternRegEx,

    [Parameter()]
    [ValidateScript( { $_ -ne '0' })]
    [string]$Occurrence = 'f',

    [Parameter()]
    [switch]$CapturedOnly,

    [Parameter()]
    [char]$Marker = 0x20DE
  )

  [System.Text.RegularExpressions.MatchCollection]$mc = $PatternRegEx.Matches($Source);

  if ($mc.Count -gt 0) {
    # Get the match instance
    #
    [System.Text.RegularExpressions.Match]$m = if ($Occurrence -eq 'f') {
      $mc[0];
    }
    elseif ($Occurrence -eq 'l') {
      $mc[$mc.Count - 1];
    }
    else {
      try {
        [int]$nth = [int]::Parse($Occurrence);
      }
      catch {
        [int]$nth = 1;
      }

      ($nth -le $mc.Count) ? $mc[$nth - 1] : $null;
    }
  }
  else {
    [System.Text.RegularExpressions.Match]$m = $null;
  }

  $result = $null;
  if ($m) {
    [string]$capturedText = $m.Value;

    $result = if ($CapturedOnly.ToBool()) {
      $capturedText;
    }
    else {
      # Splatting the arguments fails because the parameter validation in Get-InverseSubString
      # fails, due to parameters not having been bound yet.
      # https://github.com/PowerShell/PowerShell/issues/14457
      #
      [string]$remainder = $PSBoundParameters.ContainsKey('Marker') `
        ? $(Get-InverseSubString -Source $Source -StartIndex $m.Index -Length $m.Length -Marker $Marker) `
        : $(Get-InverseSubString -Source $Source -StartIndex $m.Index -Length $m.Length);

      @($capturedText, $remainder, $m);
    }
  }

  return $result;
} # Split-Match

function Update-GroupRefs {
  <#
  .NAME
    Update-GroupRefs
 
  .SYNOPSIS
    Updates group references with their captured values.
 
  .DESCRIPTION
    Returns a new string that reflects the replacement of group named references. The only
  exception is $0, meaning the whole match (not required).
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Source
    The source value containing group references. NB This MUST be an un-interpolated string,
  so if using a literal it should be in single quotes NOT double; this is to prevent the
  interpolation process from attempting to evaluate the group reference, eg ${count}.
 
  .PARAMETER Captures
    Hashtable mapping named group reference to group capture value.
 
  #>

  [OutputType([string])]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
  param(
    [Parameter()]
    [string]$Source,

    [Parameter()]
    [Hashtable]$Captures
  )

  [string]$sourceText = $Source;
  $Captures.GetEnumerator() | ForEach-Object {
    if ($_.Key -ne '0') {
      [string]$groupRef = $('${' + $_.Key + '}');
      $sourceText = $sourceText.Replace($groupRef, $_.Value);
    }
  }

  return $sourceText;
}

function Update-Match {

  <#
  .NAME
    Update-Match
 
  .SYNOPSIS
    The core update match action function principally used by Rename-Many. Updates
  $Pattern match in it's current location.
 
  .DESCRIPTION
    Returns a new string that reflects updating the specified $Pattern match.
    Firstly, Update-Match removes the Pattern match from $Value. This makes the Paste and
  Copy match against the remainder ($patternRemoved) of $Value. This way, there is
  no overlap between the Pattern match and $Paste and it also makes the functionality more
  understandable for the user. NB: Pattern only tells you what to remove, but it's the
  Copy and Paste that defines what to insert.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Copy
    Regular expression string applied to $Value (after the $Pattern match has been removed),
  indicating a portion which should be copied and re-inserted (via the $Paste parameter;
  see $Paste). Since this is a regular expression to be used in $Paste, there
  is no value in the user specifying a static pattern, because that static string can just be
  defined in $Paste. The value in the $Copy parameter comes when a non literal pattern is
  defined eg \d{3} (is non literal), specifies any 3 digits as opposed to say '123', which
  could be used directly in the $Paste parameter without the need for $Copy. The match
  defined by $Copy is stored in special variable ${_c} and can be referenced as such from
  $Paste.
 
  .PARAMETER CopyOccurrence
    Can be a number or the letters f, l
  * f: first occurrence
  * l: last occurrence
  * <number>: the nth occurrence
 
  .PARAMETER Diagnose
    switch parameter that indicates the command should be run in WhatIf mode. When enabled
  it presents additional information that assists the user in correcting the un-expected
  results caused by an incorrect/un-intended regular expression. The current diagnosis
  will show the contents of named capture groups that they may have specified. When an item
  is not renamed (usually because of an incorrect regular expression), the user can use the
  diagnostics along side the 'Not Renamed' reason to track down errors. When $Diagnose has
  been specified, $WhatIf does not need to be specified.
 
  .PARAMETER Paste
    Formatter parameter for Update operations. Can contain named/numbered group references
  defined inside regular expression parameters, or use special named references $0 for the whole
  Pattern match and ${_c} for the whole Copy match. The Paste can also contain named/numbered
  group references defined in $Pattern.
 
  .PARAMETER Pattern
    Regular expression string that indicates which part of the $Value that either needs
  to be moved or replaced as part of overall rename operation. Those characters in $Value
  which match $Pattern, are removed.
 
  .PARAMETER PatternOccurrence
    Can be a number or the letters f, l
  * f: first occurrence
  * l: last occurrence
  * <number>: the nth occurrence
 
  .PARAMETER Value
    The source value against which regular expressions are applied.
 
  .EXAMPLE 1 (Update with literal content)
  Update-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Paste '----X--X--'
 
  .EXAMPLE 2 (Update with variable content)
  [string]$today = Get-Date -Format 'yyyy-MM-dd'
  Update-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Paste $('_(' + $today + ')_')
 
  .EXAMPLE 3 (Update with whole copy reference)
  Update-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Paste '${_c},----X--X--' -Copy '[^\s]+'
 
  .EXAMPLE 4 (Update with group references)
  Update-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Paste '${first},----X--X--' -Copy '(?<first>[^\s]+)'
 
  .EXAMPLE 5 (Update with 2nd copy occurrence)
  Update-Match 'VAL 1999-02-21 + RH - CLOSE' '(?<dt>\d{4}-\d{2}-\d{2})' -Paste '${_c},----X--X--' -Copy '[^\s]+' -CopyOccurrence 2
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
  [OutputType([string])]
  param(
    [Parameter(Mandatory, Position = 0)]
    [string]$Value,

    [Parameter(Mandatory, Position = 1)]
    [System.Text.RegularExpressions.RegEx]$Pattern,

    [Parameter()]
    [ValidateScript( { $_ -ne '0' })]
    [string]$PatternOccurrence = 'f',

    [Parameter()]
    [System.Text.RegularExpressions.RegEx]$Copy,

    [Parameter()]
    [ValidateScript( { $_ -ne '0' })]
    [string]$CopyOccurrence = 'f',

    [Parameter()]
    [string]$Paste,

    [Parameter()]
    [switch]$Diagnose
  )

  function update-single {
    param (
      [Parameter()]
      [System.Text.RegularExpressions.Match]$pMatch,

      [Parameter()]
      [string]$src,

      [Parameter()]
      [string]$pOcc,

      [Parameter()]
      [RegEx]$pRegEx
    )
  }

  [string]$failedReason = [string]::Empty;
  [PSCustomObject]$diagnostics = [PSCustomObject]@{
    Named = @{}
  }

  [string]$pOccurrence = $PSBoundParameters.ContainsKey('PatternOccurrence') `
    ? $PatternOccurrence : 'f';

  [string]$capturedPattern, $patternRemoved, [System.Text.RegularExpressions.Match]$patternMatch = `
    Split-Match -Source $Value -PatternRegEx $Pattern `
    -Occurrence $pOccurrence;

  if (-not([string]::IsNullOrEmpty($capturedPattern))) {
    [Hashtable]$patternCaptures = get-Captures -MatchObject $patternMatch;
    if ($Diagnose.ToBool()) {
      $diagnostics.Named['Pattern'] = $patternCaptures;
    }
    [Hashtable]$copyCaptures = @{}

    [string]$copyText = if ($PSBoundParameters.ContainsKey('Copy')) {
      [string]$capturedCopy, $null, [System.Text.RegularExpressions.Match]$copyMatch = `
        Split-Match -Source $patternRemoved -PatternRegEx $Copy `
        -Occurrence ($PSBoundParameters.ContainsKey('CopyOccurrence') ? $CopyOccurrence : 'f');

      if (-not([string]::IsNullOrEmpty($capturedCopy))) {
        $copyCaptures = get-Captures -MatchObject $copyMatch;
        if ($Diagnose.ToBool()) {
          $diagnostics.Named['Copy'] = $copyCaptures;
        }
      }
      else {
        $failedReason = 'Copy Match';
      }
      $capturedCopy;
    }

    if ([string]::IsNullOrEmpty($failedReason)) {
      [string]$format = $PSBoundParameters.ContainsKey('Paste') `
        ? $Paste.Replace('${_c}', $copyText).Replace('$0', $capturedPattern) `
        : [string]::Empty;

      # Resolve all named/numbered group references
      #
      $format = Update-GroupRefs -Source $format -Captures $patternCaptures;
      $format = Update-GroupRefs -Source $format -Captures $copyCaptures;

      [string]$result = $Pattern.Replace($Value, $format, 1, $patternMatch.Index);
    }
  }
  else {
    $failedReason = 'Pattern Match';
  }

  [boolean]$success = $([string]::IsNullOrEmpty($failedReason));
  if (-not($success)) {
    $result = $Value;
  }

  [PSCustomObject]$updateResult = [PSCustomObject]@{
    Payload         = $result;
    Success         = $success;
    CapturedPattern = $capturedPattern;
  }

  if (-not([string]::IsNullOrEmpty($failedReason))) {
    $updateResult | Add-Member -MemberType NoteProperty -Name 'FailedReason' -Value $failedReason;
  }

  if ($Diagnose.ToBool() -and ($diagnostics.Named.Count -gt 0)) {
    $updateResult | Add-Member -MemberType NoteProperty -Name 'Diagnostics' -Value $diagnostics;
  }

  return $updateResult;
} # Update-Match


function Invoke-ForeachFsItem {
  <#
  .NAME
    Invoke-ForeachFsItem
 
  .SYNOPSIS
    Allows a custom defined script-block or function to be invoked for all file system
  objects delivered through the pipeline.
 
  .DESCRIPTION
    2 parameters sets are defined, one for invoking a named function (InvokeFunction) and
  the other (InvokeScriptBlock, the default) for invoking a script-block. An optional
  Summary script block can be specified which will be invoked at the end of the pipeline
  batch. The user should assemble the candidate items from the file system, be they files or
  directories typically using Get-ChildItem, or can be any other function that delivers
  file systems items via the PowerShell pipeline. For each item in the pipeline,
  Invoke-ForeachFsItem will invoke the script-block/function specified. Invoke-ForeachFsItem
  will deliver what ever is returned from the script-block/function, so the result of
  Invoke-ForeachFsItem can be piped to another command.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Block
    The script block to be invoked. The script block is invoked for each item in the
  pipeline that satisfy the Condition with the following positional parameters:
    * pipelineItem: the item from the pipeline
    * index: the 0 based index representing current pipeline item
    * Exchange: a hash table containing miscellaneous information gathered internally
  throughout the pipeline batch. This can be of use to the user, because it is the way
  the user can perform bi-directional communication between the invoked custom script block
  and client side logic.
    * trigger: a boolean value, useful for state changing idempotent operations. At the end
  of the batch, the state of the trigger indicates whether any of the items were actioned.
  When the script block is invoked, the trigger should indicate if the trigger was pulled for
  any of the items so far processed in the pipeline. This is the responsibility of the
  client's block implementation. The trigger is only of use for state changing operations
  and can be ignored otherwise.
 
  In addition to these fixed positional parameters, if the invoked script-block is defined
  with additional parameters, then these will also be passed in. In order to achieve this,
  the client has to provide excess parameters in BlockParam and these parameters must be
  defined as the same type and in the same order as the additional parameters in the
  script-block.
 
  .PARAMETER BlockParams
    Optional array containing the excess parameters to pass into the script block.
 
  .PARAMETER Condition
    This is a predicate script-block, which is invoked with either a DirectoryInfo or
  FileInfo object presented as a result of invoking Get-ChildItem. It provides a filtering
  mechanism that is defined by the user to define which file system objects are selected
  for function/script-block invocation.
 
  .PARAMETER Directory
    Switch to indicate that the invoked function/script-block (invokee) is to handle Directory
  objects.
 
  .PARAMETER File
    Switch to indicate that the invoked function/script-block (invokee) is to handle FileInfo
  objects. Is mutually exclusive with the Directory switch. If neither switch is specified, then
  the invokee must be able to handle both therefore the Underscore parameter it defines must
  be declared as FileSystemInfo.
 
  .PARAMETER Functee
    String defining the function to be invoked. Works in a similar way to the Block parameter
  for script-blocks. The Function's base signature is as follows:
    * Underscore: (See pipelineItem described above)
    * Index: (See index described above)
    * Exchange: (See PathThru described above)
    * Trigger: (See trigger described above)
 
  .PARAMETER FuncteeParams
    Optional hash-table containing the named parameters which are splatted into the Functee
  function invoke. As it's a hash table, order is not significant.
 
  .PARAMETER Exchange
    A hash table containing miscellaneous information gathered internally throughout the
  pipeline batch. This can be of use to the user, because it is the way the user can perform
  bi-directional communication between the invoked custom script block and client side logic.
 
  .PARAMETER Header
    A script-block that is invoked at the start of the pipeline batch. The script-block is
  invoked with the following positional parameters:
    * Exchange: (see Exchange previously described)
 
    The Header can be customised with the following Exchange entries:
    * 'LOOPZ.KRAYOLA-THEME': Krayola Theme generally in use
    * 'LOOPZ.HEADER-BLOCK.MESSAGE': message displayed as part of the header
    * 'LOOPZ.HEADER-BLOCK.CRUMB-SIGNAL': Lead text displayed in header, default: '[+] '
    * 'LOOPZ.HEADER.PROPERTIES': An array of Key/Value pairs of items to be displayed
    * 'LOOPZ.HEADER-BLOCK.LINE': A string denoting the line to be displayed. (There are
    predefined lines available to use in $LoopzUI, or a custom one can be used instead)
 
  .PARAMETER Summary
    A script-block that is invoked at the end of the pipeline batch. The script-block is
  invoked with the following positional parameters:
    * count: the number of items processed in the pipeline batch.
    * skipped: the number of items skipped in the pipeline batch. An item is skipped if
    it fails the defined condition or is not of the correct type (eg if its a directory
    but we have specified the -File flag). Also note that, if the script-block/function
    sets the Break flag causing further iteration to stop, then those subsequent items
    in the pipeline which have not been processed are not reflected in the skip count.
    * trigger: Flag set by the script-block/function, but should typically be used to
    indicate whether any of the items processed were actively updated/written in this batch.
    This helps in written idempotent operations that can be re-run without adverse
    consequences.
    * Exchange: (see Exchange previously described)
 
  .PARAMETER Top
    Restricts the number of items processed in the rename batch, the remaining items
  are skipped. User can set this for experimental purposes.
 
  .PARAMETER pipelineItem
    This is the pipeline object, so should not be specified explicitly and can represent
  a file object (System.IO.FileInfo) or a directory object (System.IO.DirectoryInfo).
 
  .EXAMPLE 1
  Invoke a script-block to handle .txt file objects from the same directory (without -Recurse):
  (NB: first parameter is of type FileInfo, -File specified on Get-ChildItem and
  Invoke-ForeachFsItem. If Get-ChildItem is missing -File, then any Directory objects passed in
  are filtered out by Invoke-ForeachFsItem. If -File is missing from Invoke-ForeachFsItem, then
  the script-block's first parameter, must be a FileSystemInfo to handle both types)
 
    [scriptblock]$block = {
      param(
        [System.IO.FileInfo]$FileInfo,
        [int]$Index,
        [hashtable]$Exchange,
        [boolean]$Trigger
      )
      ...
    }
 
    Get-ChildItem './Tests/Data/fefsi' -Recurse -Filter '*.txt' -File | `
      Invoke-ForeachFsItem -File -Block $block;
 
  .EXAMPLE 2
  Invoke a function with additional parameters to handle directory objects from multiple directories
  (with -Recurse):
 
  function invoke-Target {
    param(
      [System.IO.DirectoryInfo]$Underscore,
      [int]$Index,
      [hashtable]$Exchange,
      [boolean]$Trigger,
      [string]$Format
    )
    ...
  }
 
  [hashtable]$parameters = @{
    'Format'
  }
  Get-ChildItem './Tests/Data/fefsi' -Recurse -Directory | `
    Invoke-ForeachFsItem -Directory -Functee 'invoke-Target' -FuncteeParams $parameters
 
  .EXAMPLE 3
  Invoke a script-block to handle empty .txt file objects from the same directory (without -Recurse):
    [scriptblock]$block = {
      param(
        [System.IO.FileInfo]$FileInfo,
        [int]$Index,
        [hashtable]$Exchange,
        [boolean]$Trigger
      )
      ...
    }
 
    [scriptblock]$fileIsEmpty = {
      param(
        [System.IO.FileInfo]$FileInfo
      )
      return (0 -eq $FileInfo.Length)
    }
 
    Get-ChildItem './Tests/Data/fefsi' -Recurse -Filter '*.txt' -File | Invoke-ForeachFsItem `
      -Block $block -File -condition $fileIsEmpty;
 
  .EXAMPLE 4
  Invoke a script-block only for directories whose name starts with "A" from the same
  directory (without -Recurse); Note the use of the LOOPZ function "Select-FsItem" in the
  directory include filter:
 
    [scriptblock]$block = {
      param(
        [System.IO.FileInfo]$FileInfo,
        [int]$Index,
        [hashtable]$Exchange,
        [boolean]$Trigger
      )
      ...
    }
 
  [scriptblock]$filterDirectories = {
    [OutputType([boolean])]
    param(
      [System.IO.DirectoryInfo]$directoryInfo
    )
    Select-FsItem -Name $directoryInfo.Name -Includes 'A*';
  }
 
    Get-ChildItem './Tests/Data/fefsi' -Directory | Invoke-ForeachFsItem `
      -Block $block -Directory -DirectoryIncludes $filterDirectories;
 
  .EXAMPLE 5
  Invoke a script-block to handle .txt file objects from the same directory. This
  example illustrates the use of the Header and Summary blocks. Blocks predefined
  in LoopzHelpers are illustrated but the user can defined their own.
 
    [scriptblock]$block = {
      param(
        [System.IO.FileInfo]$FileInfo,
        [int]$Index,
        [hashtable]$Exchange,
        [boolean]$Trigger
      )
      ...
    }
 
    $exchange = @{
      'LOOPZ.KRAYOLA-THEME' = $(Get-KrayolaTheme);
      'LOOPZ.HEADER-BLOCK.MESSAGE' = 'The owls are not what they seem';
      'LOOPZ.HEADER-BLOCK.PROPERTIES' = @(@('A', 'One'), @('B', 'Two'), @('C', 'Three'));
      'LOOPZ.HEADER-BLOCK.LINE' = $LoopzUI.TildeLine;
      'LOOPZ.SUMMARY-BLOCK.LINE' = $LoopzUI.DashLine;
    }
 
    Get-ChildItem './Tests/Data/fefsi' -Recurse -Filter '*.txt' -File | `
      Invoke-ForeachFsItem -File -Block $block -Exchange $exchange `
        -Header $LoopzHelpers.DefaultHeaderBlock -Summary $LoopzHelpers.SimpleSummaryBlock;
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
  [CmdletBinding(DefaultParameterSetName = 'InvokeScriptBlock')]
  [Alias('ife', 'Foreach-FsItem')]
  param(
    [Parameter(ParameterSetName = 'InvokeScriptBlock', Mandatory, ValueFromPipeline = $true)]
    [Parameter(ParameterSetName = 'InvokeFunction', Mandatory, ValueFromPipeline = $true)]
    [System.IO.FileSystemInfo]$pipelineItem,

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$Condition = ( { return $true; }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock', Mandatory)]
    [scriptblock]$Block,

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [ValidateScript( { $_ -is [Array] })]
    $BlockParams = @(),

    [Parameter(ParameterSetName = 'InvokeFunction', Mandatory)]
    [ValidateScript( { -not([string]::IsNullOrEmpty($_)); })]
    [string]$Functee,

    [Parameter(ParameterSetName = 'InvokeFunction')]
    [hashtable]$FuncteeParams = @{},

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [hashtable]$Exchange = @{},

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$Header = ( {
        param(
          [hashtable]$_exchange
        )
      }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$Summary = ( {
        param(
          [int]$_count,
          [int]$_skipped,
          [int]$_errors,
          [boolean]$_trigger,
          [hashtable]$_exchange
        )
      }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [ValidateScript( { -not($PSBoundParameters.ContainsKey('Directory')) })]
    [switch]$File,

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [ValidateScript( { -not($PSBoundParameters.ContainsKey('File')) })]
    [switch]$Directory,

    [Parameter()]
    [ValidateScript( { $_ -gt 0 } )]
    [int]$Top
  ) # param

  begin {
    if (-not($Exchange.ContainsKey('LOOPZ.CONTROLLER'))) {
      $Exchange['LOOPZ.CONTROLLER'] = New-Controller -Type ForeachCtrl -Exchange $Exchange `
        -Header $Header -Summary $Summary;
    }
    $controller = $Exchange['LOOPZ.CONTROLLER'];
    $controller.ForeachBegin();

    [boolean]$topBreached = $false;
  }

  process {
    [boolean]$itemIsDirectory = ($pipelineItem.Attributes -band
      [System.IO.FileAttributes]::Directory) -eq [System.IO.FileAttributes]::Directory;

    [boolean]$acceptAll = -not($File.ToBool()) -and -not($Directory.ToBool());

    if (-not($controller.IsBroken())) {
      if ( $acceptAll -or ($Directory.ToBool() -and $itemIsDirectory) -or
        ($File.ToBool() -and -not($itemIsDirectory)) ) {
        if ($Condition.InvokeReturnAsIs($pipelineItem) -and -not($topBreached)) {
          [PSCustomObject]$result = $null;
          [int]$index = $controller.RequestIndex();
          [boolean]$trigger = $controller.GetTrigger();

          if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) {
            $positional = @($pipelineItem, $index, $Exchange, $trigger);

            if ($BlockParams.Length -gt 0) {
              $BlockParams | ForEach-Object {
                $positional += $_;
              }
            }

            $result = Invoke-Command -ScriptBlock $Block -ArgumentList $positional;
          }
          elseif ('InvokeFunction' -eq $PSCmdlet.ParameterSetName) {
            [hashtable]$parameters = $FuncteeParams.Clone();

            $parameters['Underscore'] = $pipelineItem;
            $parameters['Index'] = $index;
            $parameters['Exchange'] = $Exchange;
            $parameters['Trigger'] = $trigger;

            $result = & $Functee @parameters;
          }

          $controller.HandleResult($result);
          if ($result -and $result.psobject.properties.match('Product') -and $result.Product) {
            $result.Product;
          }

          if ($PSBoundParameters.ContainsKey('Top') -and ($index -eq ($Top - 1))) {
            $topBreached = $true;
          }
        }
        else {
          # IDEA! We could allow the user to provide an extra script block which we
          # invoke for skipped items and set a string containing the reason why it was
          # skipped.
          $controller.SkipItem();
        }
      }
      else {
        $controller.SkipItem();
      }
    }
    else {
      $controller.SkipItem();
    }
  }

  end {
    $controller.ForeachEnd();
  }
} # Invoke-ForeachFsItem
function Invoke-MirrorDirectoryTree {
  <#
  .NAME
    Invoke-MirrorDirectoryTree
 
  .SYNOPSIS
    Mirrors a directory tree to a new location, invoking a custom defined scriptblock
  or function as it goes.
 
  .DESCRIPTION
    Copies a source directory tree to a new location applying custom functionality for each
  directory. 2 parameters set are defined, one for invoking a named function (InvokeFunction) and
  the other (InvokeScriptBlock, the default) for invoking a scriptblock. An optional
  Summary script block can be specified which will be invoked at the end of the mirroring
  batch.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Block
    The script block to be invoked. The script block is invoked for each directory in the
  source directory tree that satisfy the specified Directory Include/Exclude filters with
  the following positional parameters:
    * underscore: the DirectoryInfo object representing the directory in the source tree
    * index: the 0 based index representing current directory in the source tree
    * Exchange object: a hash table containing miscellaneous information gathered internally
    throughout the mirroring batch. This can be of use to the user, because it is the way
    the user can perform bi-directional communication between the invoked custom script block
    and client side logic.
    * trigger: a boolean value, useful for state changing idempotent operations. At the end
    of the batch, the state of the trigger indicates whether any of the items were actioned.
    When the script block is invoked, the trigger should indicate if the trigger was pulled for
    any of the items so far processed in the batch. This is the responsibility of the
    client's script-block/function implementation.
 
  In addition to these fixed positional parameters, if the invoked scriptblock is defined
  with additional parameters, then these will also be passed in. In order to achieve this,
  the client has to provide excess parameters in BlockParams and these parameters must be
  defined as the same type and in the same order as the additional parameters in the
  script-block.
 
  The destination DirectoryInfo object can be accessed via the Exchange denoted by
  the 'LOOPZ.MIRROR.DESTINATION' entry.
 
  .PARAMETER BlockParams
    Optional array containing the excess parameters to pass into the script-block/function.
 
  .PARAMETER CopyFiles
    Switch parameter that indicates that files matching the specified filters should be copied
 
  .PARAMETER CreateDirs
    switch parameter indicates that directories should be created in the destination tree. If
  not set, then Invoke-MirrorDirectoryTree turns into a function that traverses the source
  directory invoking the function/script-block for matching directories.
 
  .PARAMETER DestinationPath
    The destination Path denoting the root of the directory tree where the source tree
  will be mirrored to.
 
  .PARAMETER DirectoryIncludes
    An array containing a list of filters, each must contain a wild-card ('*'). If a
  particular filter does not contain a wild-card, then it will be ignored. If the directory
  matches any of the filters in the list, it will be mirrored in the destination tree.
  If DirectoryIncludes contains just a single element which is the empty string, this means
  that nothing is included (rather than everything being included).
 
  .PARAMETER DirectoryExcludes
    An array containing a list of filters, each must contain a wild-card ('*'). If a
  particular filter does not contain a wild-card, then it will be ignored. If the directory
  matches any of the filters in the list, it will NOT be mirrored in the destination tree.
  Any match in the DirectoryExcludes overrides a match in DirectoryIncludes, so a directory
  that is matched in Include, can be excluded by the Exclude.
 
  .PARAMETER Exchange
    A hash table containing miscellaneous information gathered internally
  throughout the pipeline batch. This can be of use to the user, because it is the way
  the user can perform bi-directional communication between the invoked custom script block
  and client side logic.
 
  .PARAMETER FileExcludes
    An array containing a list of filters, each may contain a wild-card ('*'). If a
  particular filter does not contain a wild-card, then it will be treated as a file suffix.
  If the file in the source tree matches any of the filters in the list, it will NOT be
  mirrored in the destination tree. Any match in the FileExcludes overrides a match in
  FileIncludes, so a file that is matched in Include, can be excluded by the Exclude.
 
  .PARAMETER FileIncludes
    An array containing a list of filters, each may contain a wild-card ('*'). If a
  particular filter does not contain a wild-card, then it will be treated as a file suffix.
  If the file in the source tree matches any of the filters in the list, it will be mirrored
  in the destination tree. If FileIncludes contains just a single element which is the empty
  string, this means that nothing is included (rather than everything being included).
 
 
  .PARAMETER Functee
    String defining the function to be invoked. Works in a similar way to the Block parameter
  for script-blocks. The Function's base signature is as follows:
  * "Underscore": (See underscore described above)
  * "Index": (See index described above)
  * "Exchange": (See PathThru described above)
  * "Trigger": (See trigger described above)
 
  The destination DirectoryInfo object can be accessed via the Exchange denoted by
  the 'LOOPZ.MIRROR.DESTINATION' entry.
 
  .PARAMETER FuncteeParams
    Optional hash-table containing the named parameters which are splatted into the Functee
  function invoke. As it's a hash table, order is not significant.
 
  .PARAMETER Header
    A script-block that is invoked for each directory that also contains child directories.
  The script-block is invoked with the following positional parameters:
    * Exchange: (see Exchange previously described)
 
    The Header can be customised with the following Exchange entries:
    * 'LOOPZ.KRAYOLA-THEME': Krayola Theme generally in use
    * 'LOOPZ.HEADER-BLOCK.MESSAGE': message displayed as part of the header
    * 'LOOPZ.HEADER-BLOCK.CRUMB-SIGNAL': Lead text displayed in header, default: '[+] '
    * 'LOOPZ.HEADER.PROPERTIES': An array of Key/Value pairs of items to be displayed
    * 'LOOPZ.HEADER-BLOCK.LINE': A string denoting the line to be displayed. (There are
    predefined lines available to use in $LoopzUI, or a custom one can be used instead)
 
  .PARAMETER Hoist
    switch parameter. Without Hoist being specified, the filters can prove to be too restrictive
  on matching against directories. If a directory does not match the filters then none of its
  descendants will be considered to be mirrored in the destination tree. When Hoist is specified
  then a descendant directory that does match the filters will be mirrored even though any of
  its ancestors may not match the filters.
 
  .PARAMETER Path
    The source Path denoting the root of the directory tree to be mirrored.
 
  .PARAMETER SessionHeader
    A script-block that is invoked at the start of the mirroring batch. The script-block has
  the same signature as the Header script block.
 
  .PARAMETER SessionSummary
    A script-block that is invoked at the end of the mirroring batch. The script-block has
  the same signature as the Summary script block.
 
  .PARAMETER Summary
    A script-block that is invoked foreach directory that also contains child directories,
  after all its descendants have been processed and serves as a sub-total for the current
  directory. The script-block is invoked with the following positional parameters:
    * count: the number of items processed in the mirroring batch.
    * skipped: the number of items skipped in the mirroring batch. An item is skipped if
    it fails the defined condition or is not of the correct type (eg if its a directory
    but we have specified the -File flag).
    * errors: the number of items which resulted in error. An error occurs when the function
    or the script-block has set the Error property on the invoke result.
    * trigger: Flag set by the script-block/function, but should typically be used to
    indicate whether any of the items processed were actively updated/written in this batch.
    This helps in written idempotent operations that can be re-run without adverse
    consequences.
    * Exchange: (see Exchange previously described)
 
  .EXAMPLE 1
    Invoke a named function for every directory in the source tree and mirror every
  directory in the destination tree. The invoked function has an extra parameter in it's
  signature, so the extra parameters must be passed in via FuncteeParams (the standard
  signature being the first 4 parameters shown.)
 
  function Test-Mirror {
    param(
      [System.IO.DirectoryInfo]$Underscore,
      [int]$Index,
      [hashtable]$Exchange,
      [boolean]$Trigger,
      [string]$Format
    )
    ...
  }
 
  [hashtable]$parameters = @{
    'Format' = '---- {0} ----';
  }
  Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' `
    -DestinationPath './Tests/Data/mirror' -CreateDirs `
    -Functee 'Test-Mirror' -FuncteeParams $parameters;
 
  .EXAMPLE 2
  Invoke a script-block for every directory in the source tree and copy all files
 
  Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' `
    -DestinationPath './Tests/Data/mirror' -CreateDirs -CopyFiles -block {
      param(
        [System.IO.DirectoryInfo]$Underscore,
        [int]$Index,
        [hashtable]$Exchange,
        [boolean]$Trigger
      )
      ...
    };
 
  .EXAMPLE 3
  Mirror a directory tree, including only directories beginning with A (filter A*)
 
  Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' -DestinationPath './Tests/Data/mirror' `
    -DirectoryIncludes @('A*')
 
  Note the possible issue with this example is that any descendants named A... which are located
  under an ancestor which is not named A..., will not be mirrored;
 
  eg './Tests/Data/fefsi/Audio/mp3/A/Amorphous Androgynous', even though "Audio", "A" and
  "Amorphous Androgynous" clearly match the A* filter, they will not be mirrored because
  the "mp3" directory, would be filtered out.
  See the following example for a resolution.
 
  .EXAMPLE 4
  Mirror a directory tree, including only directories beginning with A (filter A*) regardless of
  the matching of intermediate ancestors (specifying -Hoist flag resolves the possible
  issue in the previous example)
 
  Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' -DestinationPath './Tests/Data/mirror' `
    -DirectoryIncludes @('A*') -CreateDirs -CopyFiles -Hoist
 
  Note that the directory filter must include a wild-card, otherwise it will be ignored. So a
  directory include of @('A'), is problematic, because A is not a valid directory filter so its
  ignored and there are no remaining filters that are able to include any directory, so no
  directory passes the filter.
 
  .EXAMPLE 5
  Mirror a directory tree, including files with either .flac or .wav suffix
 
  Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' -DestinationPath './Tests/Data/mirror' `
    -FileIncludes @('flac', '*.wav') -CreateDirs -CopyFiles -Hoist
 
  Note that for files, a filter may or may not contain a wild-card. If the wild-card is missing
  then it is automatically treated as a file suffix; so 'flac' means '*.flac'.
 
  .EXAMPLE 6
  Mirror a directory tree copying over just flac files
 
  [scriptblock]$summary = {
    param(
      [int]$_count,
      [int]$_skipped,
      [boolean]$_triggered,
      [hashtable]$_exchange
    )
    ...
  }
 
  Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' -DestinationPath './Tests/Data/mirror' `
    -FileIncludes @('flac') -CopyFiles -Hoist -Summary $summary
 
  Note that -CreateDirs is missing which means directories will not be mirrored by default. They
  are only mirrored as part of the process of copying over flac files, so in the end the
  resultant mirror directory tree will contain directories that include flac files.
 
  .EXAMPLE 7
  Same as EXAMPLE 6, but using predefined Header and Summary script-blocks for Session header/summary
  and per directory header/summary.
 
  Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' -DestinationPath './Tests/Data/mirror' `
  -FileIncludes @('flac') -CopyFiles -Hoist `
  -Header $LoopzHelpers.DefaultHeaderBlock -Summary $DefaultHeaderBlock.SimpleSummaryBlock `
  -SessionHeader $LoopzHelpers.DefaultHeaderBlock -SessionSummary $DefaultHeaderBlock.SimpleSummaryBlock;
  #>


  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
  [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'InvokeScriptBlock')]
  [Alias('imdt', 'Mirror-Directory')]
  param
  (
    [Parameter(Mandatory, ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(Mandatory, ParameterSetName = 'InvokeFunction')]
    [ValidateScript( { Test-path -Path $_; })]
    [String]$Path,

    [Parameter(Mandatory, ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(Mandatory, ParameterSetName = 'InvokeFunction')]
    [String]$DestinationPath,

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [String[]]$DirectoryIncludes = @('*'),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [String[]]$DirectoryExcludes = @(),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [String[]]$FileIncludes = @('*'),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [String[]]$FileExcludes = @(),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [hashtable]$Exchange = @{},

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [scriptblock]$Block = ( {
        [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
        param(
          [System.IO.DirectoryInfo]$underscore,
          [int]$index,
          [hashtable]$exchange,
          [boolean]$trigger
        )
      } ),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [ValidateScript( { $_ -is [Array] })]
    $BlockParams = @(),

    [Parameter(ParameterSetName = 'InvokeFunction', Mandatory)]
    [ValidateScript( { -not([string]::IsNullOrEmpty($_)); })]
    [string]$Functee,

    [Parameter(ParameterSetName = 'InvokeFunction')]
    [hashtable]$FuncteeParams = @{},

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [switch]$CreateDirs,

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [switch]$CopyFiles,

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [switch]$Hoist,

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$Header = ( {
        param(
          [hashtable]$_exchange
        )
      }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$Summary = ( {
        [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
        param(
          [int]$_index,
          [int]$_skipped,
          [int]$_errors,
          [boolean]$_trigger,
          [hashtable]$_exchange
        )
      }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$SessionHeader = ( {
        param(
          [hashtable]$_exchange
        )
      }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$SessionSummary = ( {
        param(
          [int]$_count,
          [int]$_skipped,
          [int]$_errors,
          [boolean]$_trigger,
          [hashtable]$_exchange
        )
      })
  ) # param

  # ================================================================== [doMirrorBlock] ===
  #
  [scriptblock]$doMirrorBlock = {
    param(
      [Parameter(Mandatory)]
      [System.IO.DirectoryInfo]$_underscore,

      [Parameter(Mandatory)]
      [int]$_index,

      [Parameter(Mandatory)]
      [hashtable]$_exchange,

      [Parameter(Mandatory)]
      [boolean]$_trigger
    )

    # Write-Host "[+] >>> doMirrorBlock: $($_underscore.Name)";

    [string]$rootSource = $_exchange['LOOPZ.MIRROR.ROOT-SOURCE'];
    [string]$rootDestination = $_exchange['LOOPZ.MIRROR.ROOT-DESTINATION'];

    $sourceDirectoryFullName = $_underscore.FullName;

    # sourceDirectoryFullName must end with directory separator
    #
    if (-not($sourceDirectoryFullName.EndsWith([System.IO.Path]::DirectorySeparatorChar))) {
      $sourceDirectoryFullName += [System.IO.Path]::DirectorySeparatorChar;
    }

    $destinationBranch = edit-RemoveSingleSubString -Target $sourceDirectoryFullName -Subtract $rootSource;

    $destinationDirectory = Join-Path -Path $rootDestination -ChildPath $destinationBranch;
    $_exchange['LOOPZ.MIRROR.BRANCH-DESTINATION'] = $destinationBranch;

    [boolean]$whatIf = $_exchange.ContainsKey('WHAT-IF') -and ($_exchange['WHAT-IF']);
    Write-Debug "[+] >>> doMirrorBlock: destinationDirectory: '$destinationDirectory'";

    if ($whatIf) {
      if (Test-Path -Path $destinationDirectory) {
        Write-Debug " [-] (WhatIf) Get existing destination branch directory: '$destinationBranch'";
        $destinationInfo = (Get-Item -Path $destinationDirectory);
      }
      else {
        Write-Debug " [-] (WhatIf) Creating synthetic destination branch directory: '$destinationBranch'";
        $destinationInfo = ([System.IO.DirectoryInfo]::new($destinationDirectory));
      }
    }
    else {
      if ($CreateDirs.ToBool()) {
        Write-Debug " [-] Creating destination branch directory: '$destinationBranch'";

        $destinationInfo = (Test-Path -Path $destinationDirectory) `
          ? (Get-Item -Path $destinationDirectory) `
          : (New-Item -ItemType 'Directory' -Path $destinationDirectory);
      }
      else {
        Write-Debug " [-] Creating destination branch directory INFO obj: '$destinationBranch'";
        $destinationInfo = New-Object -TypeName System.IO.DirectoryInfo ($destinationDirectory);
      }
    }

    if ($CopyFiles.ToBool()) {
      Write-Debug " [-] Creating files for branch directory: '$destinationBranch'";

      # To use the include/exclude parameters on Copy-Item, the Path specified
      # must end in /*. We only need to add the star though because we added the /
      # previously.
      #
      [string]$sourceDirectoryWithWildCard = $sourceDirectoryFullName + '*';

      [string[]]$adjustedFileIncludes = $FileIncludes | ForEach-Object {
        $_.Contains('*') ? $_ : "*.$_".Replace('..', '.');
      }

      [string[]]$adjustedFileExcludes = $FileExcludes | ForEach-Object {
        $_.Contains('*') ? $_ : "*.$_".Replace('..', '.');
      }

      # Ensure that the destination directory exists, but only if there are
      # files to copy over which pass the include/exclude filters. This is
      # required in the case where CreateDirs has not been specified.
      #
      [array]$filesToCopy = Get-ChildItem $sourceDirectoryWithWildCard `
        -Include $adjustedFileIncludes -Exclude $adjustedFileExcludes -File;

      if ($filesToCopy) {
        if (-not($whatIf)) {
          if (-not(Test-Path -Path $destinationDirectory)) {
            New-Item -ItemType 'Directory' -Path $destinationDirectory;
          }

          Copy-Item -Path $sourceDirectoryWithWildCard `
            -Include $adjustedFileIncludes -Exclude $adjustedFileExcludes `
            -Destination $destinationDirectory;
        }
        $_exchange['LOOPZ.MIRROR.COPIED-FILES.COUNT'] = $filesToCopy.Count;

        Write-Debug " [-] No of files copied: '$($filesToCopy.Count)'";
      }
      else {
        $_exchange.Remove('LOOPZ.MIRROR.COPIED-FILES.COUNT');
        $_exchange.Remove('LOOPZ.MIRROR.COPIED-FILES.INCLUDES');
      }
    }

    # To be consistent with Invoke-ForeachFsItem, the user function/block is invoked
    # with the source directory info. The destination for this mirror operation is
    # returned via 'LOOPZ.MIRROR.DESTINATION' within the Exchange.
    #
    $_exchange['LOOPZ.MIRROR.DESTINATION'] = $destinationInfo;

    $invokee = $_exchange['LOOPZ.MIRROR.INVOKEE'];

    if ($invokee -is [scriptblock]) {
      $positional = @($_underscore, $_index, $_exchange, $_trigger);

      if ($_exchange.ContainsKey('LOOPZ.MIRROR.INVOKEE.PARAMS')) {
        $_exchange['LOOPZ.MIRROR.INVOKEE.PARAMS'] | ForEach-Object {
          $positional += $_;
        }
      }

      $invokee.InvokeReturnAsIs($positional);
    }
    elseif ($invokee -is [string]) {
      [hashtable]$parameters = $_exchange.ContainsKey('LOOPZ.MIRROR.INVOKEE.PARAMS') `
        ? $_exchange['LOOPZ.MIRROR.INVOKEE.PARAMS'] : @{};
      $parameters['Underscore'] = $_underscore;
      $parameters['Index'] = $_index;
      $parameters['Exchange'] = $_exchange;
      $parameters['Trigger'] = $_trigger;

      & $invokee @parameters;
    }
    else {
      Write-Warning "User defined function/block not valid, not invoking.";
    }

    @{ Product = $destinationInfo }
  } #doMirrorBlock

  # ===================================================== [Invoke-MirrorDirectoryTree] ===

  [string]$resolvedSourcePath = Convert-Path $Path;
  [string]$resolvedDestinationPath = Convert-Path $DestinationPath;

  $Exchange['LOOPZ.MIRROR.ROOT-SOURCE'] = $resolvedSourcePath;
  $Exchange['LOOPZ.MIRROR.ROOT-DESTINATION'] = $resolvedDestinationPath;

  if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) {
    $Exchange['LOOPZ.MIRROR.INVOKEE'] = $Block;

    if ($BlockParams.Count -gt 0) {
      $Exchange['LOOPZ.MIRROR.INVOKEE.PARAMS'] = $BlockParams;
    }
  }
  else {
    $Exchange['LOOPZ.MIRROR.INVOKEE'] = $Functee;

    if ($FuncteeParams.PSBase.Count -gt 0) {
      $Exchange['LOOPZ.MIRROR.INVOKEE.PARAMS'] = $FuncteeParams.Clone();
    }
  }

  if ($PSBoundParameters.ContainsKey('WhatIf') -and ($true -eq $PSBoundParameters['WhatIf'])) {
    $Exchange['WHAT-IF'] = $true;
  }

  if ($CopyFiles.ToBool()) {
    $Exchange['LOOPZ.MIRROR.COPIED-FILES.INCLUDES'] = $FileIncludes -join ', ';
  }

  [scriptblock]$filterDirectories = {
    [OutputType([boolean])]
    param(
      [System.IO.DirectoryInfo]$directoryInfo
    )
    Select-FsItem -Name $directoryInfo.Name `
      -Includes $DirectoryIncludes -Excludes $DirectoryExcludes;
  }

  $null = Invoke-TraverseDirectory -Path $resolvedSourcePath `
    -Block $doMirrorBlock -Exchange $Exchange -Header $Header -Summary $Summary `
    -SessionHeader $SessionHeader -SessionSummary $SessionSummary -Condition $filterDirectories -Hoist:$Hoist;
} # Invoke-MirrorDirectoryTree

function Invoke-TraverseDirectory {
  <#
  .NAME
    Invoke-TraverseDirectory
 
  .SYNOPSIS
    Traverses a directory tree invoking a custom defined script-block or named function
  as it goes.
 
  .DESCRIPTION
    Navigates a directory tree applying custom functionality for each directory. A Condition
  script-block can be applied for conditional functionality. 2 parameters set are defined, one
  for invoking a named function (InvokeFunction) and the other (InvokeScriptBlock, the default)
  for invoking a scriptblock. An optional Summary script block can be specified which will be
  invoked at the end of the traversal batch.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Block
    The script block to be invoked. The script block is invoked for each directory in the
  source directory tree that satisfy the specified Condition predicate with
  the following positional parameters:
    * underscore: the DirectoryInfo object representing the directory in the source tree
    * index: the 0 based index representing current directory in the source tree
    * Exchange object: a hash table containing miscellaneous information gathered internally
    throughout the mirroring batch. This can be of use to the user, because it is the way
    the user can perform bi-directional communication between the invoked custom script block
    and client side logic.
    * trigger: a boolean value, useful for state changing idempotent operations. At the end
    of the batch, the state of the trigger indicates whether any of the items were actioned.
    When the script block is invoked, the trigger should indicate if the trigger was pulled for
    any of the items so far processed in the batch. This is the responsibility of the
    client's script-block/function implementation.
 
  In addition to these fixed positional parameters, if the invoked scriptblock is defined
  with additional parameters, then these will also be passed in. In order to achieve this,
  the client has to provide excess parameters in BlockParams and these parameters must be
  defined as the same type and in the same order as the additional parameters in the
  script-block.
 
  .PARAMETER BlockParams
    Optional array containing the excess parameters to pass into the script-block.
 
  .PARAMETER Condition
    This is a predicate script-block, which is invoked with a DirectoryInfo object presented
  as a result of invoking Get-ChildItem. It provides a filtering mechanism that is defined
  by the user to define which directories are selected for function/script-block invocation.
 
  .PARAMETER Exchange
    A hash table containing miscellaneous information gathered internally throughout the
  traversal batch. This can be of use to the user, because it is the way the user can perform
  bi-directional communication between the invoked custom script block and client side logic.
 
  .PARAMETER Functee
    String defining the function to be invoked. Works in a similar way to the Block parameter
  for script-blocks. The Function's base signature is as follows:
  * "Underscore": (See underscore described above)
  * "Index": (See index described above)
  * "Exchange": (See PathThru described above)
  * "Trigger": (See trigger described above)
 
  The destination DirectoryInfo object can be accessed via the Exchange denoted by
  the 'LOOPZ.MIRROR.DESTINATION' entry.
 
  .PARAMETER FuncteeParams
    Optional hash-table containing the named parameters which are splatted into the Functee
  function invoke. As it's a hash table, order is not significant.
 
  .PARAMETER Header
    A script-block that is invoked for each directory that also contains child directories.
  The script-block is invoked with the following positional parameters:
    * Exchange: (see Exchange previously described)
 
    The Header can be customised with the following Exchange entries:
    * 'LOOPZ.KRAYOLA-THEME': Krayola Theme generally in use
    * 'LOOPZ.HEADER-BLOCK.MESSAGE': message displayed as part of the header
    * 'LOOPZ.HEADER-BLOCK.CRUMB-SIGNAL': Lead text displayed in header, default: '[+] '
    * 'LOOPZ.HEADER.PROPERTIES': An array of Key/Value pairs of items to be displayed
    * 'LOOPZ.HEADER-BLOCK.LINE': A string denoting the line to be displayed. (There are
    predefined lines available to use in $LoopzUI, or a custom one can be used instead)
 
  .PARAMETER Hoist
    Switch parameter. Without Hoist being specified, the Condition can prove to be too restrictive
  on matching against directories. If a directory does not match the Condition then none of its
  descendants will be considered to be traversed. When Hoist is specified then a descendant directory
  that does match the Condition will be traversed even though any of its ancestors may not match the
  same Condition.
 
  .PARAMETER Path
    The source Path denoting the root of the directory tree to be traversed.
 
  .PARAMETER SessionHeader
    A script-block that is invoked at the start of the traversal batch. The script-block has
  the same signature as the Header script block.
 
  .PARAMETER SessionSummary
    A script-block that is invoked at the end of the traversal batch. The script-block has
  the same signature as the Summary script block.
 
  .PARAMETER Summary
    A script-block that is invoked foreach directory that also contains child directories,
  after all its descendants have been processed and serves as a sub-total for the current
  directory. The script-block is invoked with the following positional parameters:
    * count: the number of items processed in the mirroring batch.
    * skipped: the number of items skipped in the mirroring batch. An item is skipped if
    it fails the defined condition or is not of the correct type (eg if its a directory
    but we have specified the -File flag).
    * errors: the number of items which resulted in error. An error occurs when the function
    or the script-block has set the Error property on the invoke result.
    * trigger: Flag set by the script-block/function, but should typically be used to
    indicate whether any of the items processed were actively updated/written in this batch.
    This helps in written idempotent operations that can be re-run without adverse
    consequences.
    * Exchange: (see Exchange previously described)
 
  .EXAMPLE 1
    Invoke a script-block for every directory in the source tree.
 
    [scriptblock]$block = {
      param(
        $underscore,
        [int]$index,
        [hashtable]$exchange,
        [boolean]$trigger
      )
      ...
    }
 
  Invoke-TraverseDirectory -Path './Tests/Data/fefsi' -Block $block
 
  .EXAMPLE 2
    Invoke a named function with extra parameters for every directory in the source tree.
 
  function Test-Traverse {
    param(
      [System.IO.DirectoryInfo]$Underscore,
      [int]$Index,
      [hashtable]$Exchange,
      [boolean]$Trigger,
      [string]$Format
    )
    ...
  }
  [hashtable]$parameters = @{
    'Format' = "=== {0} ===";
  }
 
  Invoke-TraverseDirectory -Path './Tests/Data/fefsi' `
    -Functee 'Test-Traverse' -FuncteeParams $parameters;
 
  .EXAMPLE 3
  Invoke a named function, including only directories beginning with A (filter A*)
 
  function Test-Traverse {
    param(
      [System.IO.DirectoryInfo]$Underscore,
      [int]$Index,
      [hashtable]$Exchange,
      [boolean]$Trigger
    )
    ...
  }
 
  [scriptblock]$filterDirectories = {
    [OutputType([boolean])]
    param(
      [System.IO.DirectoryInfo]$directoryInfo
    )
 
    Select-FsItem -Name $directoryInfo.Name -Includes @('A*');
  }
 
  Invoke-TraverseDirectory -Path './Tests/Data/fefsi' -Functee 'Test-Traverse' `
    -Condition $filterDirectories;
 
  Note the possible issue with this example is that any descendants named A... which are located
  under an ancestor which is not named A..., will not be processed by the provided function
 
  .EXAMPLE 4
  Mirror a directory tree, including only directories beginning with A (filter A*) regardless of
  the matching of intermediate ancestors (specifying -Hoist flag resolves the possible
  issue in the previous example)
 
  function Test-Traverse {
    param(
      [System.IO.DirectoryInfo]$Underscore,
      [int]$Index,
      [hashtable]$Exchange,
      [boolean]$Trigger
    )
    ...
  }
 
  [scriptblock]$filterDirectories = {
    [OutputType([boolean])]
    param(
      [System.IO.DirectoryInfo]$directoryInfo
    )
 
    Select-FsItem -Name $directoryInfo.Name -Includes @('A*');
  }
 
  Invoke-TraverseDirectory -Path './Tests/Data/fefsi' -Functee 'Test-Traverse' `
    -Condition $filterDirectories -Hoist;
 
  Note that the directory filter must include a wild-card, otherwise it will be ignored. So a
  directory include of @('A'), is problematic, because A is not a valid directory filter so its
  ignored and there are no remaining filters that are able to include any directory, so no
  directory passes the filter.
 
  .EXAMPLE 5
  Same as EXAMPLE 4, but using predefined Header and Summary script-blocks for Session header/summary
  and per directory header/summary. (Test-Traverse and filterDirectories as per EXAMPLE 4)
 
  Invoke-TraverseDirectory -Path './Tests/Data/fefsi' -Functee 'Test-Traverse' `
    -Condition $filterDirectories -Hoist `
    -Header $LoopzHelpers.DefaultHeaderBlock -Summary $DefaultHeaderBlock.SimpleSummaryBlock `
    -SessionHeader $LoopzHelpers.DefaultHeaderBlock -SessionSummary $DefaultHeaderBlock.SimpleSummaryBlock;
 
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
  [CmdletBinding(DefaultParameterSetName = 'InvokeScriptBlock')]
  [Alias('itd', 'Traverse-Directory')]
  param
  (
    [Parameter(ParameterSetName = 'InvokeScriptBlock', Mandatory)]
    [Parameter(ParameterSetName = 'InvokeFunction', Mandatory)]
    [ValidateScript( { Test-path -Path $_ })]
    [String]$Path,

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [ValidateScript( { -not($_ -eq $null) })]
    [scriptblock]$Condition = (
      {
        [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
        param([System.IO.DirectoryInfo]$directoryInfo)
        return $true;
      }
    ),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [hashtable]$Exchange = @{},

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [ValidateScript( { -not($_ -eq $null) })]
    [scriptblock]$Block,

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [ValidateScript( { $_ -is [Array] })]
    $BlockParams = @(),

    [Parameter(ParameterSetName = 'InvokeFunction', Mandatory)]
    [ValidateScript( { -not([string]::IsNullOrEmpty($_)); })]
    [string]$Functee,

    [Parameter(ParameterSetName = 'InvokeFunction')]
    [hashtable]$FuncteeParams = @{},

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$Header = ( {
        param(
          [hashtable]$_exchange
        )
      }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$Summary = ( {
        [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
        param(
          [int]$_count,
          [int]$_skipped,
          [int]$_errors,
          [boolean]$_triggered,
          [hashtable]$_exchange
        )
      }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$SessionHeader = ( {
        param(
          [hashtable]$_exchange
        )
      }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [scriptblock]$SessionSummary = ( {
        param(
          [int]$_count,
          [int]$_skipped,
          [int]$_errors,
          [boolean]$_trigger,
          [hashtable]$_exchange
        )
      }),

    [Parameter(ParameterSetName = 'InvokeScriptBlock')]
    [Parameter(ParameterSetName = 'InvokeFunction')]
    [switch]$Hoist
  ) # param

  # ======================================================= [recurseTraverseDirectory] ===
  #
  [scriptblock]$recurseTraverseDirectory = { # Invoked by adapter
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    param(
      [Parameter(Position = 0, Mandatory)]
      [System.IO.DirectoryInfo]$directoryInfo,

      [Parameter(Position = 1)]
      [ValidateScript( { -not($_ -eq $null) })]
      [scriptblock]$condition,

      [Parameter(Position = 2, Mandatory)]
      [ValidateScript( { -not($_ -eq $null) })]
      [hashtable]$exchange,

      [Parameter(Position = 3)]
      [ValidateScript( { ($_ -is [scriptblock]) -or ($_ -is [string]) })]
      $invokee, # (scriptblock or function name; hence un-typed parameter)

      [Parameter(Position = 4)]
      [boolean]$trigger
    )

    $result = $null;
    $index = $exchange['LOOPZ.FOREACH.INDEX'];

    # This is the invoke, for the current directory
    #
    if ($invokee -is [scriptblock]) {
      $positional = @($directoryInfo, $index, $exchange, $trigger);

      if ($exchange.ContainsKey('LOOPZ.TRAVERSE.INVOKEE.PARAMS') -and
        ($exchange['LOOPZ.TRAVERSE.INVOKEE.PARAMS'].Count -gt 0)) {
        $exchange['LOOPZ.TRAVERSE.INVOKEE.PARAMS'] | ForEach-Object {
          $positional += $_;
        }
      }
      $result = $invokee.InvokeReturnAsIs($positional);
    }
    else {
      [hashtable]$parameters = $exchange.ContainsKey('LOOPZ.TRAVERSE.INVOKEE.PARAMS') `
        ? $exchange['LOOPZ.TRAVERSE.INVOKEE.PARAMS'] : @{};

      # These are directory specific overwrites. The custom parameters
      # will still be present
      #
      $parameters['Underscore'] = $directoryInfo;
      $parameters['Index'] = $index;
      $parameters['Exchange'] = $exchange;
      $parameters['Trigger'] = $trigger;

      $result = & $invokee @parameters;
    }

    [string]$fullName = $directoryInfo.FullName;
    [System.IO.DirectoryInfo[]]$directoryInfos = Get-ChildItem -Path $fullName `
      -Directory | Where-Object { $condition.InvokeReturnAsIs($_) };

    [scriptblock]$adapter = $Exchange['LOOPZ.TRAVERSE.ADAPTOR'];

    if ($directoryInfos) {
      # adapter is always a script block, this has nothing to do with the invokee,
      # which may be a script block or a named function(functee)
      #
      $directoryInfos | Invoke-ForeachFsItem -Directory -Block $adapter `
        -Exchange $Exchange -Condition $condition -Summary $Summary;
    }

    return $result;
  } # recurseTraverseDirectory

  # ======================================================================== [adapter] ===
  #
  [scriptblock]$adapter = {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    param(
      [Parameter(Mandatory)]
      [System.IO.DirectoryInfo]$_underscore,

      [Parameter(Mandatory)]
      [int]$_index,

      [Parameter(Mandatory)]
      [hashtable]$_exchange,

      [Parameter(Mandatory)]
      [boolean]$_trigger
    )

    [scriptblock]$adapted = $_exchange['LOOPZ.TRAVERSE.ADAPTED'];
    $controller = $_exchange['LOOPZ.CONTROLLER'];

    try {
      $adapted.InvokeReturnAsIs(
        $_underscore,
        $_exchange['LOOPZ.TRAVERSE.CONDITION'],
        $_exchange,
        $Exchange['LOOPZ.TRAVERSE.INVOKEE'],
        $_trigger
      );
    }
    catch [System.Management.Automation.MethodInvocationException] {
      $controller.ErrorItem();
      # This is a mystery exception, that has no effect on processing the batch:
      #
      # Exception calling ".ctor" with "2" argument(s): "Count cannot be less than zero.
      #
      # Resolve-Error
      # Write-Error "Problem with: '$_underscore'" -ErrorAction Stop;
    }
    catch {
      $controller.ErrorItem();
      Write-Error "[!] Error: $($_.Exception.Message)" -ErrorAction Continue;

      throw;
    }
  } # adapter

  # ======================================================= [Invoke-TraverseDirectory] ===

  $controller = New-Controller -Type TraverseCtrl -Exchange $Exchange `
    -Header $Header -Summary $Summary -SessionHeader $SessionHeader -SessionSummary $SessionSummary;
  $Exchange['LOOPZ.CONTROLLER'] = $controller;

  $controller.BeginSession();

  # Handle top level directory, before recursing through child directories
  #
  [System.IO.DirectoryInfo]$directory = Get-Item -Path $Path;

  [boolean]$itemIsDirectory = ($directory.Attributes -band
    [System.IO.FileAttributes]::Directory) -eq [System.IO.FileAttributes]::Directory;

  if ($itemIsDirectory) {
    if ($Condition.InvokeReturnAsIs($directory)) {
      [boolean]$trigger = $controller.GetTrigger();

      # The index of the top level directory is always 0
      #
      [int]$index = $controller.RequestIndex();

      if ('InvokeFunction' -eq $PSCmdlet.ParameterSetName) {
        # set-up custom parameters
        #
        [hashtable]$parameters = $FuncteeParams.Clone();
        $parameters['Underscore'] = $directory;
        $parameters['Index'] = $index;
        $parameters['Exchange'] = $Exchange;
        $parameters['Trigger'] = $trigger;
        $Exchange['LOOPZ.TRAVERSE.INVOKEE.PARAMS'] = $parameters;
      }
      elseif ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) {
        $positional = @($directory, $index, $Exchange, $trigger);

        if ($BlockParams.Count -gt 0) {
          $BlockParams | Foreach-Object {
            $positional += $_;
          }
        }

        # Note, for the positional parameters, we can only pass in the additional
        # custom parameters provided by the client here via the Exchange otherwise
        # we could accidentally build up the array of positional parameters with
        # duplicated entries. This is in contrast to splatted arguments for function
        # invokes where parameter names are paired with parameter values in a
        # hashtable and naturally prevent duplicated entries. This is why we set
        # 'LOOPZ.TRAVERSE.INVOKEE.PARAMS' to $BlockParams and not $positional.
        #
        $Exchange['LOOPZ.TRAVERSE.INVOKEE.PARAMS'] = $BlockParams;
      }

      $result = $null;

      # This is the top level invoke
      #
      try {
        if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) {
          $result = $Block.InvokeReturnAsIs($positional);
        }
        elseif ('InvokeFunction' -eq $PSCmdlet.ParameterSetName) {
          $result = & $Functee @parameters;
        }
      }
      catch {
        $result = [PSCustomObject]@{
          ErrorReason = "Unhandled Error: $($_.Exception.Message)";
        }
      }
      finally {
        $controller.HandleResult($result);
      }
    }
    else {
      $controller.SkipItem();
    }

    # --- end of top level invoke ----------------------------------------------------------

    if ($Hoist.ToBool()) {
      # Perform non-recursive retrieval of descendant directories
      #
      [System.IO.DirectoryInfo[]]$directoryInfos = Get-ChildItem -Path $Path `
        -Directory -Recurse | Where-Object { $Condition.InvokeReturnAsIs($_) }

      Write-Debug " [o] Invoke-TraverseDirectory (Hoist); Count: $($directoryInfos.Count)";

      if ($directoryInfos) {
        # No need to manage the index, let Invoke-ForeachFsItem do this for us,
        # except we do need to inform Invoke-ForeachFsItem to start the index at
        # +1, because 0 is for the top level directory which has already been
        # handled.
        #
        [hashtable]$parametersFeFsItem = @{
          'Directory' = $true;
          'Exchange'  = $Exchange;
          'Summary'   = $Summary;
        }

        if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) {
          $parametersFeFsItem['Block'] = $Block;
          $parametersFeFsItem['BlockParams'] = $BlockParams;
        }
        else {
          $parametersFeFsItem['Functee'] = $Functee;
          $parametersFeFsItem['FuncteeParams'] = $FuncteeParams;
        }

        $directoryInfos | & 'Invoke-ForeachFsItem' @parametersFeFsItem;
      }
    }
    else {
      # Top level descendants
      #
      # Set up the adapter. (NB, can't use splatting because we're invoking a script block
      # as opposed to a named function.)
      #
      $Exchange['LOOPZ.TRAVERSE.CONDITION'] = $Condition;
      $Exchange['LOOPZ.TRAVERSE.ADAPTED'] = $recurseTraverseDirectory;
      $Exchange['LOOPZ.TRAVERSE.ADAPTOR'] = $adapter;

      if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) {
        $Exchange['LOOPZ.TRAVERSE.INVOKEE'] = $Block;
      }
      elseif ('InvokeFunction' -eq $PSCmdlet.ParameterSetName) {
        $Exchange['LOOPZ.TRAVERSE.INVOKEE'] = $Functee;
      }

      # Now perform start of recursive traversal
      #
      [System.IO.DirectoryInfo[]]$directoryInfos = Get-ChildItem -Path $Path `
        -Directory | Where-Object { $Condition.InvokeReturnAsIs($_) }

      if ($directoryInfos) {
        $directoryInfos | Invoke-ForeachFsItem -Directory -Block $adapter `
          -Exchange $Exchange -Condition $Condition -Summary $Summary;
      }
    }
  }
  else {
    $controller.SkipItem();
    Write-Error "Path specified '$($Path)' is not a directory";
  }
} # Invoke-TraverseDirectory

function Format-BooleanCellValue {
  <#
  .NAME
    Format-BooleanCellValue
 
  .SYNOPSIS
    Table Render callback that can be passed into Show-AsTable
 
  .DESCRIPTION
    For table cells containing boolean fields, this callback function will
  render the cell with alternative values other than 'true' or 'false'. Typically,
  the client would set the alternative representation of these boolean values
  (the default values are emoji values 'SWITCH-ON'/'SWITCH-OFF') in the table
  options.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER TableOptions
    The PSCustomObject that contains the alternative boolean values (./Values/True
  and ./Values/False)
 
  .PARAMETER Value
    The original boolean value in string form.
  #>

  [OutputType([string])]
  param(
    [Parameter()]
    [string]$Value,

    [Parameter()]
    [PSCustomObject]$TableOptions
  )

  [string]$coreValue = $value.Trim() -eq 'True' `
    ? $TableOptions.Values.True : $TableOptions.Values.False;

  [string]$cellValue = Get-PaddedLabel -Label $coreValue -Width $value.Length `
    -Align $TableOptions.Align.Cell;

  return $cellValue;
}

function Format-StructuredLine {
  <#
  .NAME
    Format-StructuredLine
 
  .SYNOPSIS
    Helper function to make it easy to generate a line to be displayed.
 
  .DESCRIPTION
    A structured line is some text that includes embedded colour instructions that
  will be interpreted by the Krayola krayon writer. This function behaves like a
  layout manager for a single line.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER CrumbKey
    The key used to index into the $Exchange hashtable to denote which crumb is used.
 
  .PARAMETER Exchange
    The exchange hashtable object.
 
  .PARAMETER LineKey
    The key used to index into the $Exchange hashtable to denote the core line.
 
  .PARAMETER MessageKey
    The key used to index into the $Exchange hashtable to denote what message to display.
 
  .PARAMETER Options
 
   + this is the crumb
   |
   V <-- message ->|
  [@@] --------------------------------------------------- [ Rename (WhatIf) ] ---
                                                                                |<-- This is a trailing wing
                                                                                whose length is WingLength
       |<--- flex part (which must be at least -------->|
                        MinimumFlexSize in length, it shrinks to accommodate the message)
 
    A PSCustomObject that allows further customisation of the structured line. Can contain the following
  fields:
  - WingLength: The size of the lead and tail portions of the line ('---')
  - MinimumFlexSize: The smallest size that the flex part can shrink to, to accommodate
  the message. If the message is so large that is pushes up against the minimal flex size
  it will be truncated according to the presence of Truncate switch
  - Ellipses: When message truncation occurs, the ellipses string is used to indicate that
  the message has been truncated.
  - WithLead: boolean flag to indicate whether a leading wing is displayed which would precede
  the crumb. In the above example and by default, there is no leading wing.
 
  .PARAMETER Truncate
    switch parameter to indicate whether the message is truncated to fit the line length.
 
  #>

  [OutputType([string])]
  param(
    [Parameter(Mandatory)]
    [hashtable]$Exchange,

    # We need to replace the exchange key parameters with direct parameters
    [Parameter(Mandatory)]
    [string]$LineKey,

    [Parameter()]
    [string]$CrumbKey,

    [Parameter()]
    [string]$MessageKey,

    [Parameter()]
    [switch]$Truncate,

    [Parameter()]
    [PSCustomObject]$Options = (@{
        WingLength      = 3;
        MinimumFlexSize = 6;
        Ellipses        = ' ...';
        WithLead        = $false;
      })
  )
  [Scribbler]$scribbler = $Exchange['LOOPZ.SCRIBBLER'];
  if (-not($scribbler)) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      "Format-StructuredLine: Scribbler missing from Exchange under key 'LOOPZ.SCRIBBLER'");
  }
  [Krayon]$krayon = $scribbler.Krayon;

  [string]$lnSnippet = $scribbler.Snippets(@('Ln'));
  [string]$metaSnippet = $scribbler.WithArgSnippet('ThemeColour', 'meta');
  [string]$messageSnippet = $scribbler.WithArgSnippet('ThemeColour', 'message');

  [int]$wingLength = Get-PsObjectField $Options 'WingLength' 3;
  [int]$minimumFlexSize = Get-PsObjectField $Options 'MinimumFlexSize' 6;
  [string]$ellipses = Get-PsObjectField $Options 'Ellipses' ' ...';
  [boolean]$withLead = Get-PsObjectField $Options 'WithLead' $false;

  [hashtable]$theme = $krayon.Theme;

  [string]$line = $Exchange.ContainsKey($LineKey) `
    ? $Exchange[$LineKey] : ([string]::new("_", 81));
  [string]$char = ($line -match '[^\s]') ? $matches[0] : ' ';
  [string]$wing = [string]::new($char, $wingLength);

  [string]$message = -not([string]::IsNullOrEmpty($MessageKey)) -and ($Exchange.ContainsKey($MessageKey)) `
    ? $Exchange[$MessageKey] : $null;

  [string]$crumb = if (-not([string]::IsNullOrEmpty($CrumbKey)) -and ($Exchange.ContainsKey($CrumbKey))) {
    if ($Exchange.ContainsKey('LOOPZ.SIGNALS')) {
      [hashtable]$signals = $Exchange['LOOPZ.SIGNALS'];
      [string]$crumbName = $Exchange[$CrumbKey];
      $signals[$crumbName].Value;
    }
    else {
      '+';
    }
  }
  else {
    $null;
  }

  [string]$structuredLine = if ([string]::IsNullOrEmpty($message) -and [string]::IsNullOrEmpty($crumb)) {
    $("$($metaSnippet)$($line)$($lnSnippet)");
  }
  else {
    [string]$open = $theme['OPEN'];
    [string]$close = $theme['CLOSE'];

    # TODO: The deductions need to be calculated in a dynamic form, to cater
    # for optional fields.
    #

    if (-not([string]::IsNullOrEmpty($message)) -and -not([string]::IsNullOrEmpty($crumb))) {
      # 'lead' + 'open' + 'crumb' + 'close' + 'mid' + 'open' + 'message' + 'close' + 'tail'
      #
      # '*{lead} *{open}*{crumb}*{close} *{mid} *{open} *{message} *{close} *{tail}' => Format
      # +-----------------------------------------------|--------|-----------------+
      # | Start | | End | => Snippet
      #
      [string]$startFormat = '*{open}*{crumb}*{close} *{mid} *{open} ';
      if ($withLead) {
        $startFormat = '*{lead} ' + $startFormat;
      }
      [string]$endFormat = ' *{close} *{tail}';

      [string]$startSnippet = $startFormat.Replace('*{lead}', $wing). `
        Replace('*{open}', $open). `
        Replace('*{crumb}', $crumb). `
        Replace('*{close}', $close);

      [string]$endSnippet = $endFormat.Replace('*{close}', $close). `
        Replace('*{tail}', $wing);

      [string]$withoutMid = $startSnippet.Replace('*{mid}', '') + $endSnippet;
      [int]$deductions = $withoutMid.Length + $minimumFlexSize;
      [int]$messageSpace = $line.Length - $deductions;
      [boolean]$overflow = $message.Length -gt $messageSpace;

      [int]$midSize = if ($overflow) {
        # message size is the unknown variable and $midSize is a known
        # quantity: minimumFlexSize.
        #
        if ($Truncate.ToBool()) {
          [int]$messageKeepAmount = $messageSpace - $ellipses.Length;
          $message = $message.Substring(0, $messageKeepAmount) + $ellipses;
        }
        $minimumFlexSize;
      }
      else {
        # midSize is the unknown variable and the message size is a known
        # quantity: $message.Length
        #
        [int]$deductions = $withoutMid.Length + $message.Length;
        $line.Length - $deductions;
      }

      [string]$mid = [string]::new($char, $midSize);
      $startSnippet = $startSnippet.Replace('*{mid}', $mid);

      $(
        "$($metaSnippet)$($startSnippet)" +
        "$($messageSnippet)$($message)" +
        "$($metaSnippet)$($endSnippet)$($lnSnippet)"
      );
    }
    elseif (-not([string]::IsNullOrEmpty($message))) {
      # 'lead' + 'open' + 'message' + 'close' + 'tail'
      #
      # '*{lead} *{open} *{message} *{close} *{tail}'
      # +----------------|--------|-----------------+
      # | Start | | End | => Snippet
      #
      # The lead is mandatory in this case so ignore withLead
      #
      [string]$startFormat = '*{lead} *{open} ';
      [string]$endFormat = ' *{close} *{tail}';
      [string]$endSnippet = $endFormat.Replace('*{close}', $close). `
        Replace('*{tail}', $wing);

      [string]$withoutLead = $startFormat.Replace('*{lead}', ''). `
        Replace('*{open}', $open);

      [int]$deductions = $minimumFlexSize + $withoutLead.Length + $endSnippet.Length;
      [int]$messageSpace = $line.Length - $deductions;
      [boolean]$overflow = $message.Length -gt $messageSpace;
      [int]$leadSize = if ($overflow) {
        if ($Truncate.ToBool()) {
          # Truncate the message
          #
          [int]$messageKeepAmount = $messageSpace - $Ellipses.Length;
          $message = $message.Substring(0, $messageKeepAmount) + $Ellipses;
        }
        $minimumFlexSize;
      }
      else {
        $line.Length - $withoutLead.Length - $message.Length - $endSnippet.Length;
      }

      # The lead is now the variable part so should be calculated last
      #
      [string]$lead = [string]::new($char, $leadSize);
      [string]$startSnippet = $startFormat.Replace('*{lead}', $lead). `
        Replace('*{open}', $open);

      $(
        "$($metaSnippet)$($startSnippet)" +
        "$($messageSnippet)$($message)" +
        "$($metaSnippet)$($endSnippet)$($lnSnippet)"
      );
    }
    elseif (-not([string]::IsNullOrEmpty($crumb))) {
      # 'lead' + 'open' + 'crumb' + 'close' + 'tail'
      #
      # '*{lead} *{open}*{crumb}*{close} *{tail}'
      # +--------------------------------+------+
      # |Start |End | => 2 Snippets can be combined into lineSnippet
      # because they use the same colours.
      #
      [string]$startFormat = '*{open}*{crumb}*{close} ';
      if ($withLead) {
        $startFormat = '*{lead} ' + $startFormat;
      }

      [string]$startFormat = '*{open}*{crumb}*{close} ';
      [string]$startSnippet = $startFormat.Replace('*{lead}', $wing). `
        Replace('*{open}', $open). `
        Replace('*{crumb}', $crumb). `
        Replace('*{close}', $close);

      [string]$withTailFormat = $startSnippet + '*{tail}';
      [int]$deductions = $startSnippet.Length;
      [int]$tailSize = $line.Length - $deductions;
      [string]$tail = [string]::new($char, $tailSize);
      [string]$lineSnippet = $withTailFormat.Replace('*{tail}', $tail);

      $("$($metaSnippet)$($lineSnippet)$($lnSnippet)");
    }
  }

  return $structuredLine;
}

function Get-AsTable {
  <#
  .NAME
    Get-AsTable
 
  .SYNOPSIS
    Selects the table header and data from source and meta data.
 
  .DESCRIPTION
    The client can override the behaviour to perform custom evaluation of
  table cell values. The default will space pad the cell value and align
  according the table options (./HeaderAlign and ./ValueAlign).
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Evaluate
    A script-block allowing client defined cell rendering logic. The Render script-block
  contains the following parameters:
  - Value: the current value of the cell being rendered.
  - columnData: column meta data
  - isHeader: flag to indicate if the current cell being evaluated is a header, if false
  then it is a data cell.
  - Options: The table display options
 
  .PARAMETER MetaData
    Hashtable instance which maps column titles to a PSCustomObject instance that
  contains display information pertaining to that column. The object must contain
  the following members:
   
  - FieldName: the name of the column
  - Max: the size of the largest value found in the table data for that column
  - Type: the type of data represented by that column
 
  .PARAMETER Options
    The table display options (See Get-TableDisplayOptions)
 
  .PARAMETER TableData
    Hashtable containing the table data.
  #>

  [OutputType([hashtable])]
  param(
    [Parameter()]
    [hashtable]$MetaData,

    [Parameter()]
    [PSCustomObject[]]$TableData,

    [Parameter()]
    [PSCustomObject]$Options,

    [Parameter()]
    [scriptblock]$Evaluate = $([scriptblock] {
        [OutputType([string])]
        param(
          [string]$value,
          [PSCustomObject]$columnData,
          [boolean]$isHeader,
          [PSCustomObject]$Options
        )
        # If the client wants to use this default, the meta data must
        # contain an int Max field denoting the max value size.
        #
        $max = $columnData.Max;

        [string]$align = $isHeader ? $Options.HeaderAlign : $Options.ValueAlign;
        return $(Get-PaddedLabel -Label $value -Width $max -Align $align);
      }
    )
  )
  # NB The table returned, uses the 'Name' as the row's key, and implies 2 things
  # 1) the table must have a Name column
  # 2) no 2 rows can have the same Name
  # This could be improved upon in the future to remove these 2 limitations
  #
  [hashtable]$headers = @{}
  $MetaData.GetEnumerator() | ForEach-Object {
    $headers[$_.Key] = $Evaluate.InvokeReturnAsIs($_.Key, $MetaData[$_.Key], $true, $Options)
  }

  [hashtable]$table = @{}
  foreach ($row in $TableData) {
    [PSCustomObject]$insert = @{}
    foreach ($field in $row.psobject.properties.name) { # .name here should not be assumed; it should be custom ID field
      [string]$raw = $row.$field;
      [string]$cell = $Evaluate.InvokeReturnAsIs($raw, $MetaData[$field], $false, $Options);
      $insert.$field = $cell;
    }
    # Insert table row here
    #
    $table[$row.Name] = $insert;
  }

  return $headers, $table;
}

function Get-SyntaxScheme {
  <#
  .NAME
    Get-SyntaxScheme
 
  .SYNOPSIS
    Get the scheme instance required by Command Syntax functionality in the
  parameter set tools.
 
  .DESCRIPTION
    The scheme is related to the Krayola theme. Some of the entries in the scheme
  are derived from the Krayola theme. The colours are subject to the presence of
  the environment variable 'KRAYOLA_LIGHT_TERMINAL', this is to prevent light
  foreground colours being selected when the background is also using light colours.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Theme
    The Krayola theme that the scheme will be associated with.
  #>

  [OutputType([Hashtable])]
  param(
    [Parameter()]
    [Hashtable]$Theme
  )
  [Hashtable]$scheme = @{
    'COLS.PUNCTUATION'    = $Theme['META-COLOURS'];
    'COLS.HEADER'         = 'black', 'bgYellow';
    'COLS.HEADER-UL'      = 'darkYellow';
    'COLS.UNDERLINE'      = $Theme['META-COLOURS'];
    'COLS.CELL'           = 'gray';
    'COLS.TYPE'           = 'darkCyan';
    'COLS.MAN-PARAM'      = $Theme['AFFIRM-COLOURS'];
    'COLS.OPT-PARAM'      = 'blue'
    'COLS.CMD-NAME'       = 'darkGreen';
    'COLS.PARAM-SET-NAME' = 'green';
    'COLS.SWITCH'         = 'cyan';
    'COLS.HI-LIGHT'       = 'white';
    'COLS.SPECIAL'        = 'darkYellow';
    'COLS.ERROR'          = 'black', 'bgRed';
    'COLS.OK'             = 'black', 'bgGreen';
    'COLS.COMMON'         = 'magenta';
  }

  if (Get-IsKrayolaLightTerminal) {
    $scheme['COLS.CELL'] = 'magenta';
    $scheme['COLS.HI-LIGHT'] = 'black';
  }

  return $scheme;
}

function Get-TableDisplayOptions {
  <#
  .NAME
    Get-TableDisplayOptions
 
  .SYNOPSIS
    Gets the default table display options.
 
  .DESCRIPTION
    The client can further customise by overwriting the members on the
  PSCustomObject returned.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Custom
    A client defined PSCustomObject that will be populated under the ./Custom in the
  PSCustomObject returned.
 
  .PARAMETER Scribbler
    The Krayola scribbler instance used to manage rendering to console.
 
  .PARAMETER Select
    An array of strings defining which columns are selected to be shown in the table.
 
  .PARAMETER Signals
    The signals hashtable collection from which to select the signals.
 
  #>

  [OutputType([PSCustomObject])]
  param(
    [Parameter()]
    [hashtable]$Signals,

    [Parameter()]
    [Object]$Scribbler,

    [Parameter()]
    [string[]]$Select,

    [Parameter()]
    [PSCustomObject]$Custom = $null
  )

  [string]$trueValue = ($PSBoundParameters.ContainsKey('Signals') -and
    $Signals.ContainsKey('SWITCH-ON')) `
    ? $signals['SWITCH-ON'].Value : 'true';

  [string]$falseValue = ($PSBoundParameters.ContainsKey('Signals') -and
    $Signals.ContainsKey('SWITCH-OFF')) `
    ? $signals['SWITCH-OFF'].Value : 'false';

  [string]$titleMainColour = if (${Custom}?.Colours?.Title) {
    $Custom.Colours.Title;
  }
  else {
    'darkYellow';
  }

  [string]$titleLoColour = 'black';
  [string]$titleBackColour = 'bg' + $titleMainColour;

  [PSCustomObject]$tableOptions = [PSCustomObject]@{
    Select   = $Select;

    Chrome   = [PSCustomObject]@{
      Indent         = 3;
      Underline      = '=';
      TitleUnderline = '-<>-';
      Inter          = 1;
    }

    Colours  = [PSCustomObject]@{
      Header    = 'blue';
      Cell      = 'white';
      Underline = 'yellow';
      HiLight   = 'green';
      Title     = $titleMainColour;
      TitleLo   = $titleLoColour;
    }

    Values   = [PSCustomObject]@{
      True  = $trueValue;
      False = $falseValue;
    }

    Align    = [PSCustomObject]@{
      Header = 'right';
      Cell   = 'left';
    }

    Snippets = [PSCustomObject]@{
      Reset          = $($Scribbler.Snippets('Reset'));
      Ln             = $($Scribbler.Snippets('Ln'));
      Title          = $($Scribbler.Snippets(@($titleLoColour, $titleBackColour)));
      TitleUnderline = $($Scribbler.Snippets($titleMainColour));
    }

    Custom   = $Custom;
  }

  return $tableOptions;
}

function Show-AsTable {
  <#
  .NAME
    Show-AsTable
 
  .SYNOPSIS
    Shows the provided data in a coloured table form.
 
  .DESCRIPTION
    Requires table meta data, headers and values and renders the content according
  to the options provided. The clint can override the default cell rendering behaviour
  by providing a render function.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Headers
    Hashtable instance that represents the headers displayed for the table. Maps the
  raw column title to the actual text used to display it. In practice, this is a
  space padded version of the raw title determined from the meta data.
 
  .PARAMETER MetaData
    Hashtable instance which maps column titles to a PSCustomObject instance that
  contains display information pertaining to that column. The object must contain
 
  - FieldName: the name of the column
  - Max: the size of the largest value found in the table data for that column
  - Type: the type of data represented by that column
   
  .PARAMETER Options
    The table display options (See command Get-TableDisplayOptions)
 
  .PARAMETER Render
    A script-block allowing client defined cell rendering logic. The Render script-block
  contains the following parameters:
  - Column: spaced padded column title, indicating which column this cell is in.
  - Value: the current value of the cell being rendered.
  - row: a PSCustomObject containing all the field values for the current row. The whole
  row is presented to the cell render function so that cross field functionality can be
  defined.
  - Options: The table display options
  - Scribbler: The Krayola scribbler instance
  - counter: the row number
 
  .PARAMETER Scribbler
    The Krayola scribbler instance used to manage rendering to console.
 
  .PARAMETER Table
    Hashtable containing the table data. Currently, the data row is indexed by the
  'Name' property and as such, the Name in in row must be unique (actually acts
  like its the primary key for the table; this will be changed in future so that
  an alternative ID field is used instead of Name.)
 
  .PARAMETER Title
    If provided, this will be shown as the title for this table.
 
  .PARAMETER TitleFormat
    A table title format string which must contain a {0} place holder for the Title
  to be inserted into.
  #>

  param(
    [Parameter()]
    [hashtable]$MetaData,

    [Parameter()]
    [hashtable]$Headers,

    [Parameter()]
    [hashtable]$Table,

    [Parameter()]
    [string]$Title,

    [Parameter()]
    [string]$TitleFormat = "--- [ {0} ] ---",
  
    [Parameter()]
    [Scribbler]$Scribbler,

    [Parameter()]
    [scriptblock]$Render = $([scriptblock] {
        [OutputType([boolean])]
        param(
          [string]$column,
          [string]$Value,
          [PSCustomObject]$row,
          [PSCustomObject]$Options,
          [Scribbler]$Scribbler,
          [boolean]$counter
        )
        return $false;
      }),

    [Parameter()]
    [PSCustomObject]$Options
  )
  # TODO: The client should be able to define a render method on a per column basis.
  # Currently there is just a single render callback supported. And ideally, we should be
  # able to present the entire row, not just the current cell. This will enable cross
  # field functionality to be defined.
  #
  [string]$indentation = [string]::new(' ', $Options.Chrome.Indent);
  [string]$inter = [string]::new(' ', $Options.Chrome.Inter);
  [string]$headerSnippet = $($Options.Custom.Snippets.Header);
  [string]$underlineSnippet = $($Options.Custom.Snippets.Underline);
  [string]$resetSnippet = $($Options.Snippets.Reset);
  [string]$lnSnippet = $($Options.Snippets.Ln);
  
  $Scribbler.Scribble("$($resetSnippet)$($lnSnippet)");

  if (($MetaData.PSBase.Count -gt 0) -and ($Table.PSBase.Count -gt 0)) {
    if ($PSBoundParameters.ContainsKey('Title') -and -not([string]::IsNullOrEmpty($Title))) {
      [string]$titleSnippet = $($Options.Snippets.Title);
      [string]$titleUnderlineSnippet = $($Options.Snippets.TitleUnderline);
      [string]$adornedTitle = $TitleFormat -f $Title;
      [int]$ulLength = $Options.Chrome.TitleUnderline.Length;

      [string]$underline = $Options.Chrome.TitleUnderline * $(($adornedTitle.Length / $ulLength) + 1);

      if ($underline.Length -gt $adornedTitle.Length) {
        $underline = $underline.Substring(0, $adornedTitle.Length);
      }

      [string]$titleFragment = $(
        "$($lnSnippet)" +
        "$($indentation)$($titleSnippet)$($adornedTitle)$($resetSnippet)" +
        "$($lnSnippet)" +
        "$($indentation)$($titleUnderlineSnippet)$($underline)$($resetSnippet)" +
        "$($resetSnippet)$($lnSnippet)$($lnSnippet)"
      );
      $Scribbler.Scribble($titleFragment);
    }

    # Establish field selection
    #
    [string[]]$selection = Get-PsObjectField -Object $Options -Field 'Select';

    if (-not($selection)) {
      $selection = $Headers.PSBase.Keys;
    }

    # Display column titles
    #
    $Scribbler.Scribble($indentation);

    foreach ($col in $selection) {
      [string]$paddedValue = $Headers[$col.Trim()];
      $Scribbler.Scribble("$($headerSnippet)$($paddedValue)$($resetSnippet)$($inter)");
    }
    $Scribbler.Scribble("$($lnSnippet)$($resetSnippet)");

    # Display column underlines
    #
    $Scribbler.Scribble($indentation);
    foreach ($col in $selection) {
      $underline = [string]::new($Options.Chrome.Underline, $MetaData[$col].Max);
      $Scribbler.Scribble("$($underlineSnippet)$($underline)$($inter)$($resetSnippet)");
    }
    $Scribbler.Scribble($lnSnippet);

    # Display field values
    #
    [int]$counter = 1;
    $Table.GetEnumerator() | Sort-Object Name | ForEach-Object { # Name here should be custom ID field which should probably default to 'ID'
      $Scribbler.Scribble($indentation);

      foreach ($col in $selection) {
        if (-not($Render.InvokeReturnAsIs(
          $col, $_.Value.$col, $_.Value, $Options, $Scribbler, $counter))
        ) {
          $Scribbler.Scribble("$($resetSnippet)$($_.Value.$col)");
        }
        $Scribbler.Scribble($inter);
      }

      $Scribbler.Scribble($lnSnippet);
      $counter++;
    }
  }
}

function Show-Header {
  <#
  .NAME
    Show-Header
 
  .SYNOPSIS
    Function to display header as part of an iteration batch.
 
  .DESCRIPTION
    Behaviour can be customised by the following entries in the Exchange:
  * 'LOOPZ.SCRIBBLER' (mandatory): the Krayola Scribbler writer object.
  * 'LOOPZ.HEADER-BLOCK.MESSAGE': The custom message to be displayed as
  part of the header.
  * 'LOOPZ.HEADER.PROPERTIES': A Krayon [line] instance contain a collection
  of Krayola [couplet]s. When present, the header displayed will be a static
  line, the collection of these properties then another static line.
  * 'LOOPZ.HEADER-BLOCK.LINE': The static line text. The length of this line controls
  how everything else is aligned (ie the flex part and the message if present).
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Exchange
    The exchange hashtable object.
 
  #>

  param(
    [Parameter()]
    [hashtable]$Exchange
  )
  [Scribbler]$scribbler = $Exchange['LOOPZ.SCRIBBLER'];
  if (-not($scribbler)) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      "Show-Header: Scribbler missing from Exchange under key 'LOOPZ.SCRIBBLER'");
  }

  [string]$resetSnippet = $scribbler.Snippets(@('Reset'));
  [string]$lnSnippet = $scribbler.Snippets(@('Ln'));
  [string]$metaSnippet = $scribbler.WithArgSnippet('ThemeColour', 'meta');

  $scribbler.Scribble("$($resetSnippet)");

  [string]$message = $Exchange.ContainsKey(
    'LOOPZ.HEADER-BLOCK.MESSAGE') ? $Exchange['LOOPZ.HEADER-BLOCK.MESSAGE'] : [string]::Empty;

  # get the properties from Exchange ('LOOPZ.HEADER.PROPERTIES')
  # properties should not be a line, because line implies all these properties are
  # written on the same line. Rather it is better described as array of Krayola pairs,
  # which is does not share the same semantics as line. However, since line is just a
  # collection of pairs, the client can use the line abstraction, but not the line
  # method on the writer, unless they want it to actually represent a line.
  #
  [line]$properties = $Exchange.ContainsKey(
    'LOOPZ.HEADER.PROPERTIES') ? $Exchange['LOOPZ.HEADER.PROPERTIES'] : [line]::new();

  if ($properties.Line.Length -gt 0) {
    # First line
    #
    [string]$line = $Exchange.ContainsKey('LOOPZ.HEADER-BLOCK.LINE') `
      ? $Exchange['LOOPZ.HEADER-BLOCK.LINE'] : ([string]::new('_', 80));

    [string]$structuredLine = $metaSnippet + $line;
    $scribbler.Scribble("$($structuredLine)$($lnSnippet)");

    # Inner detail
    #
    if (-not([string]::IsNullOrEmpty($message))) {
      [string]$messageSnippet = $scribbler.WithArgSnippet('Message', $message);
      $scribbler.Scribble("$($messageSnippet)");
    }

    [string]$structuredProps = ($properties.Line | ForEach-Object {
        "$($_.Key),$($_.Value),$($_.Affirm)"
      }) -join ';'

    [string]$lineSnippet = $scribbler.WithArgSnippet(
      'Line', $structuredProps
    )
    $scribbler.Scribble("$($lineSnippet)");
          
    # Second line
    #
    $scribbler.Scribble("$($structuredLine)$($lnSnippet)");
  }
  else {
    # Alternative line
    #
    [string]$lineKey = 'LOOPZ.HEADER-BLOCK.LINE';
    [string]$crumbKey = 'LOOPZ.HEADER-BLOCK.CRUMB-SIGNAL';
    [string]$messageKey = 'LOOPZ.HEADER-BLOCK.MESSAGE';

    [string]$structuredLine = Format-StructuredLine -Exchange $exchange `
      -LineKey $LineKey -CrumbKey $CrumbKey -MessageKey $messageKey -Truncate;

    $scribbler.Scribble("$($structuredLine)");
  }
}

function Show-Summary {
  <#
  .NAME
    Show-Header
 
  .SYNOPSIS
    Function to display summary as part of an iteration batch.
 
  .DESCRIPTION
    Behaviour can be customised by the following entries in the Exchange:
  * 'LOOPZ.SCRIBBLER' (mandatory): the Krayola Scribbler writer object.
  * 'LOOPZ.SUMMARY-BLOCK.MESSAGE': The custom message to be displayed as
  part of the summary.
  * 'LOOPZ.SUMMARY.PROPERTIES': A Krayon [line] instance contain a collection
  of Krayola [couplet]s. The first line of summary properties shows the values of
  $Count, $Skipped and $Triggered. The properties, if present are appended to this line.
  * 'LOOPZ.SUMMARY-BLOCK.LINE': The static line text. The length of this line controls
  how everything else is aligned (ie the flex part and the message if present).
  * 'LOOPZ.SUMMARY-BLOCK.WIDE-ITEMS': The collection (an array of Krayola [lines]s)
  containing 'wide' items and therefore should be on their own separate line.
  * 'LOOPZ.SUMMARY-BLOCK.GROUP-WIDE-ITEMS': Perhaps the wide items are not so wide after
  all, so if this entry is set (a boolean value), the all wide items appear on their
  own line.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Count
    The number items processed, this is the number of items in the pipeline which match
  the $Pattern specified and therefore are allocated an index.
 
  .PARAMETER Errors
    The number of errors that occurred during the batch.
 
  .PARAMETER Exchange
    The exchange hashtable object.
 
  .PARAMETER Skipped
    The number of pipeline items skipped. An item is skipped for the following reasons:
  * Item name does not match the $Include expression
  * Item name satisfies the $Exclude expression. ($Exclude overrides $Include)
  * Iteration is terminated early by the invoked function/script-block returning a
  PSCustomObject with a Break property set to $true.
  * FileSystem item is not of the request type. Eg, if File is specified, then all
  directory items will be skipped.
  * An item fails to satisfy the $Condition predicate.
  * Number of items processed breaches Top.
 
  .PARAMETER Triggered
    Indicates whether any of the processed pipeline items were actioned in a modifying
  batch; ie if no items were mutated, then Triggered would be $false.
 
  #>

  param(
    [Parameter()]
    [int]$Count,

    [Parameter()]
    [int]$Skipped,

    [Parameter()]
    [int]$Errors,

    [Parameter()]
    [boolean]$Triggered,

    [Parameter()]
    [hashtable]$Exchange = @{}
  )

  [Scribbler]$scribbler = $Exchange['LOOPZ.SCRIBBLER'];
  if (-not($scribbler)) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      "Show-Summary: Scribbler missing from Exchange under key 'LOOPZ.SCRIBBLER'");
  }

  [string]$resetSnippet = $scribbler.Snippets(@('Reset'));
  [string]$lnSnippet = $scribbler.Snippets(@('Ln'));
  [string]$metaSnippet = $scribbler.WithArgSnippet('ThemeColour', 'meta');

  $scribbler.Scribble("$($resetSnippet)");

  # First line
  #
  if ($Exchange.ContainsKey('LOOPZ.SUMMARY-BLOCK.LINE')) {
    [string]$line = $Exchange['LOOPZ.SUMMARY-BLOCK.LINE'];
    [string]$structuredBorderLine = $metaSnippet + $line;

    $scribbler.Scribble("$($structuredBorderLine)$($lnSnippet)");
  }
  else {
    $structuredBorderLine = [string]::Empty;
  }

  # Inner detail
  #

  [string]$message = $Exchange.ContainsKey('LOOPZ.SUMMARY-BLOCK.MESSAGE') `
    ? $Exchange['LOOPZ.SUMMARY-BLOCK.MESSAGE'] : 'Summary';

  [string]$structuredPropsWithMessage = $(
    "$message;Count,$Count;Skipped,$Skipped;Errors,$Errors;Triggered,$Triggered"
  );

  [string]$structuredPropsWithMessage = if ($Triggered -and `
      $Exchange.ContainsKey('LOOPZ.FOREACH.TRIGGER-COUNT')) {

    [int]$triggerCount = $Exchange['LOOPZ.FOREACH.TRIGGER-COUNT'];
    $(
      "$message;Count,$Count;Skipped,$Skipped;Errors,$Errors;Triggered,$triggerCount"
    );
  }
  else {
    $(
      "$message;Count,$Count;Skipped,$Skipped;Errors,$Errors;Triggered,$Triggered"
    );
  }

  [string]$lineSnippet = $scribbler.WithArgSnippet(
    'Line', $structuredPropsWithMessage
  )
  $scribbler.Scribble("$($lineSnippet)");

  [string]$blank = [string]::new(' ', $message.Length);

  # Custom properties
  #
  [line]$summaryProperties = $Exchange.ContainsKey(
    'LOOPZ.SUMMARY.PROPERTIES') ? $Exchange['LOOPZ.SUMMARY.PROPERTIES'] : [line]::new(@());

  if ($summaryProperties.Line.Length -gt 0) {
    $scribbler.Line($blank, $summaryProperties).End();
  }

  # Wide items
  #
  if ($Exchange.ContainsKey('LOOPZ.SUMMARY-BLOCK.WIDE-ITEMS')) {
    [line]$wideItems = $Exchange['LOOPZ.SUMMARY-BLOCK.WIDE-ITEMS'];

    [boolean]$group = ($Exchange.ContainsKey('LOOPZ.SUMMARY-BLOCK.GROUP-WIDE-ITEMS') -and
      $Exchange['LOOPZ.SUMMARY-BLOCK.GROUP-WIDE-ITEMS']);

    if ($group) {
      $scribbler.Line($blank, $wideItems).End();
    }
    else {
      foreach ($couplet in $wideItems.Line) {
        [line]$syntheticLine = New-Line($couplet);

        $scribbler.Line($blank, $syntheticLine).End();
      }
    }
  }

  # Second line
  #
  if (-not([string]::IsNullOrEmpty($structuredBorderLine))) {
    $scribbler.Scribble("$($structuredBorderLine)$($lnSnippet)");
  }
}
function Write-HostFeItemDecorator {
  <#
  .NAME
    Write-HostFeItemDecorator
 
  .SYNOPSIS
    Wraps a function or script-block as a decorator writing appropriate user interface
    info to the host for each entry in the pipeline.
 
  .DESCRIPTION
      The script-block/function (invokee) being decorated may or may not Support ShouldProcess. If it does,
    then the client should add 'WHAT-IF' to the pass through, set to the current
    value of WhatIf; or more accurately the existence of 'WhatIf' in PSBoundParameters. Or another
    way of putting it is, the presence of WHAT-IF indicates SupportsShouldProcess, and the value of
    'WHAT-IF' dictates the value of WhatIf. This way, we only need a single
    value in the Exchange, rather than having to represent SupportShouldProcess explicitly with
    another value.
      The Exchange must contain either a 'LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME' entry meaning
    a named function is being decorated or 'LOOPZ.WH-FOREACH-DECORATOR.BLOCK' meaning a script
    block is being decorated, but not both.
      By default, Write-HostFeItemDecorator will display an item no for each object in the pipeline
    and a property representing the Product. The Product is a property that the invokee can set on the
    PSCustomObject it returns. However, additional properties can be displayed. This can be achieved by
    the invokee populating another property Pairs, which is an array of string based key/value pairs. All
    properties found in Pairs will be written out by Write-HostFeItemDecorator.
      By default, to render the value displayed (ie the 'Product' property item on the PSCustomObject
    returned by the invokee), ToString() is called. However, the 'Product' property may not have a
    ToString() method, in this case (you will see an error indicating ToString method not being
    available), the user should provide a custom script-block to determine how the value is
    constructed. This can be done by assigning a custom script-block to the
    'LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT' entry in Exchange. eg:
 
      [scriptblock]$customGetResult = {
        param($result)
        $result.SomeCustomPropertyOfRelevanceThatIsAString;
      }
      $Exchange['LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT'] = $customGetResult;
      ...
 
      Note also, the user can provide a custom 'GET-RESULT' in order to control what is displayed
    by Write-HostFeItemDecorator.
 
      This function is designed to be used with Invoke-ForeachFsItem and as such, it's signature
    needs to match that required by Invoke-ForeachFsItem. Any additional parameters can be
    passed in via the Exchange.
      The rationale behind Write-HostFeItemDecorator is to maintain separation of concerns
    that allows development of functions that could be used with Invoke-ForeachFsItem which do
    not contain any UI related code. This strategy also helps for the development of different
    commands that produce output to the terminal in a consistent manner.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Exchange
    A hash table containing miscellaneous information gathered internally
    throughout the iteration batch. This can be of use to the user, because it is the way
    the user can perform bi-directional communication between the invoked custom script block
    and client side logic.
 
  .PARAMETER Index
    The 0 based index representing current item in the pipeline.
 
  .PARAMETER Trigger
      A boolean value, useful for state changing idempotent operations. At the end
    of the batch, the state of the trigger indicates whether any of the items were actioned.
    When the script block is invoked, the trigger should indicate if the trigger was pulled for
    any of the items so far processed in the batch. This is the responsibility of the
    client's block implementation.
 
  .PARAMETER Underscore
    The current pipeline object.
 
  .RETURNS
    The result of invoking the decorated script-block.
 
  .EXAMPLE 1
 
  function Test-FN {
    param(
      [System.IO.DirectoryInfo]$Underscore,
      [int]$Index,
      [hashtable]$Exchange,
      [boolean]$Trigger,
    )
 
    $format = $Exchange['CLIENT.FORMAT'];
    @{ Product = $format -f $Underscore.Name, $Underscore.Exists }
    ...
  }
 
  [Systems.Collection.Hashtable]$exchange = @{
    'LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME' = 'Test-FN';
    'CLIENT.FORMAT' = '=== [{0}] -- [{1}] ==='
  }
 
  Get-ChildItem ... | Invoke-ForeachFsItem -Path <path> -Exchange $exchange
    -Functee 'Write-HostFeItemDecorator'
 
    So, Test-FN is not concerned about writing any output to the console, it simply does
  what it does silently and Write-HostFeItemDecorator handles generation of output. It
  invokes the function defined in 'LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME' and generates
  corresponding output. It happens to use the console colouring facility provided by a
  a dependency Elizium.Krayola to create colourful output in a predefined format via the
  Krayola Theme.
 
  Note, Write-HostFeItemDecorator does not forward additional parameters to the decorated
  function (Test-FN), but this can be circumvented via the Exchange as illustrated by
  the 'CLIENT.FORMAT' parameter in this example.
 
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding()]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
  [Alias('wife', 'Decorate-Foreach')]
  param (
    [Parameter(
      Mandatory = $true
    )]
    $Underscore,

    [Parameter(
      Mandatory = $true
    )]
    [int]$Index,

    [Parameter(
      Mandatory = $true
    )]
    [ValidateScript( {
        return ($_.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME') -xor
          $_.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.BLOCK'))
      })]
    [hashtable]
    $Exchange,

    [Parameter()]
    [boolean]$Trigger
  )
  [Scribbler]$scribbler = $Exchange['LOOPZ.SCRIBBLER'];
  [scriptblock]$defaultGetResult = {
    param($result)
    $result.ToString();
  }

  [scriptblock]$decorator = {
    param ($_underscore, $_index, $_exchange, $_trigger)

    if ($_exchange.Contains('LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME')) {
      [string]$functee = $_exchange['LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME'];

      [hashtable]$parameters = @{
        'Underscore' = $_underscore;
        'Index'      = $_index;
        'Exchange'   = $_exchange;
        'Trigger'    = $_trigger;
      }
      if ($_exchange.Contains('WHAT-IF')) {
        $parameters['WhatIf'] = $_exchange['WHAT-IF'];
      }

      return & $functee @parameters;
    }
    elseif ($_exchange.Contains('LOOPZ.WH-FOREACH-DECORATOR.BLOCK')) {
      [scriptblock]$block = $_exchange['LOOPZ.WH-FOREACH-DECORATOR.BLOCK'];

      return $block.InvokeReturnAsIs($_underscore, $_index, $_exchange, $_trigger);
    }
  }

  $invokeResult = $decorator.InvokeReturnAsIs($Underscore, $Index, $Exchange, $Trigger);

  [string]$message = $Exchange['LOOPZ.WH-FOREACH-DECORATOR.MESSAGE'];
  [string]$productValue = [string]::Empty;
  [boolean]$ifTriggered = $Exchange.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.IF-TRIGGERED');
  [boolean]$resultIsTriggered = $invokeResult.psobject.properties.match('Trigger') -and $invokeResult.Trigger;

  # Suppress the write if client has set IF-TRIGGERED and the result is not triggered.
  # This makes re-runs of a state changing operation less verbose if that's required.
  #
  if (-not($ifTriggered) -or ($resultIsTriggered)) {
    $getResult = $Exchange.Contains('LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT') `
      ? $Exchange['LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT'] : $defaultGetResult;

    [line]$themedPairs = $( New-Line( $(New-Pair('No', $("{0,3}" -f ($Index + 1)))) ) );

    # Get Product if it exists
    #
    [string]$productLabel = [string]::Empty;
    if ($invokeResult -and $invokeResult.psobject.properties.match('Product') -and $invokeResult.Product) {
      [boolean]$affirm = $invokeResult.psobject.properties.match('Affirm') -and $invokeResult.Affirm;
      $productValue = $getResult.InvokeReturnAsIs($invokeResult.Product);
      $productLabel = $Exchange.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.PRODUCT-LABEL') `
        ? $Exchange['LOOPZ.WH-FOREACH-DECORATOR.PRODUCT-LABEL'] : 'Product';

      if (-not([string]::IsNullOrWhiteSpace($productLabel))) {
        $themedPairs.append($(New-Pair(@($productLabel, $productValue, $affirm))));
      }
    }

    # Get Key/Value Pairs
    #
    if ($invokeResult -and $invokeResult.psobject.properties.match('Pairs') -and
      $invokeResult.Pairs -and ($invokeResult.Pairs -is [line]) -and ($invokeResult.Pairs.Line.Count -gt 0)) {
      $themedPairs.append($invokeResult.Pairs);
    }

    # Write the primary line
    #
    if (-not([string]::IsNullOrEmpty($message))) {
      [string]$messageSnippet = $scribbler.WithArgSnippet('Message', $message);
      $scribbler.Scribble("$($messageSnippet)");
    }
    $scribbler.Line($themedPairs).End();

    [int]$indent = $($Exchange.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.INDENT') `
        ? $Exchange['LOOPZ.WH-FOREACH-DECORATOR.INDENT'] : 3);
    [string]$blank = [string]::new(' ', $indent);

    [System.Collections.Generic.List[line]]$additionalLines = @();

    if ($invokeResult -and $invokeResult.psobject.properties.match('Lines') -and $invokeResult.Lines) {
      $invokeResult.Lines.foreach( { $additionalLines.Add($_) });
    }

    foreach ($line in $additionalLines) {
      $scribbler.NakedLine($blank, $line).End();
    }
  }

  return $invokeResult;
} # Write-HostFeItemDecorator

function Edit-RemoveSingleSubString {
  <#
.NAME
  edit-RemoveSingleSubString
 
.SYNOPSIS
  Removes a sub-string from the target string provided.
 
.DESCRIPTION
  Either the first or the last occurrence of a single substring can be removed
  depending on whether the Last flag has been set.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
.PARAMETER Last
  Flag to indicate whether the last occurrence of a sub string is to be removed from the
  Target.
 
.PARAMETER Subtract
  The sub string to subtract from the Target.
 
.PARAMETER Target
  The string from which the subtraction is to occur.
 
.PARAMETER Insensitive
  Flag to indicate if the search is case sensitive or not. By default, search is case
  sensitive.
 
.EXAMPLE 1
  $result = edit-RemoveSingleSubString -Target "Twilight and Willow's excellent adventure" -Subtract "excellent ";
 
  Returns "Twilight and Willow's adventure"
#>

  [CmdletBinding(DefaultParameterSetName = 'Single')]
  [OutputType([string])]
  param
  (
    [Parameter(ParameterSetName = 'Single')]
    [String]$Target,

    [Parameter(ParameterSetName = 'Single')]
    [String]$Subtract,

    [Parameter(ParameterSetName = 'Single')]
    [switch]$Insensitive,

    [Parameter(ParameterSetName = 'Single')]
    [Parameter(ParameterSetName = 'LastOnly')]
    [switch]$Last
  )

  [StringComparison]$comparison = $Insensitive.ToBool() ? `
    [StringComparison]::OrdinalIgnoreCase : [StringComparison]::Ordinal;

  $result = $Target;

  # https://docs.microsoft.com/en-us/dotnet/standard/base-types/best-practices-strings
  #
  if (($Subtract.Length -gt 0) -and ($Target.Contains($Subtract, $comparison))) {
    $slen = $Subtract.Length;

    $foundAt = $Last.ToBool() ? $Target.LastIndexOf($Subtract, $comparison) : `
      $Target.IndexOf($Subtract, $comparison);

    if ($foundAt -eq 0) {
      $result = $Target.Substring($slen);
    }
    elseif ($foundAt -gt 0) {
      $result = $Target.Substring(0, $foundAt);
      $result += $Target.Substring($foundAt + $slen);
    }
  }

  $result;
}

function Get-FieldMetaData {
  <#
  .NAME
    Get-FieldMetaData
 
  .SYNOPSIS
    Derives the meta data from the table data provided.
 
  .DESCRIPTION
    The source table data is just an array of PSCustomObjects where each object
  represents a row in the table. The meta data is required to format the table
  cells correctly so that each cell is properly aligned.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Data
    Hashtable containing the table data.
  #>

  param(
    [Parameter()]
    [ValidateScript( { $_ -and $_.Count -gt 0 })]
    [PSCustomObject[]]$Data
  )
  [hashtable]$fieldMetaData = @{}

  # Just look at the first row, so we can see each field
  #
  foreach ($field in $Data[0].psobject.properties.name) {

    try { 
      $fieldMetaData[$field] = [PSCustomObject]@{
        FieldName = $field;
        # !array compound statement: => .$field
        # Note, we also add the field name to the collection, because the field name
        # might be larger than any of the field values
        #
        Max       = Get-LargestLength $($Data.$field + $field);
        Type      = $Data[0].$field.GetType();
      }
    }
    catch {
      Write-Debug "Get-FieldMetaData, ERR: (field: '$field')";
      Write-Debug "Get-FieldMetaData, ERR: (field length: '$($field.Length)')";
      Write-Debug "Get-FieldMetaData, ERR: (fieldMetaData defined?: $($null -ne $fieldMetaData))";
      Write-Debug "Get-FieldMetaData, ERR: (type: '$($field.GetType())')";
      Write-Debug "Get-FieldMetaData, ERR: ($($_.Exception.Message))";
      Write-Debug "..."
      # TODO: find out why this is happening
      # Strange error sometimes occurs with Get-LargestLength on boolean fields
      #
      $fieldMetaData[$field] = [PSCustomObject]@{
        FieldName = $field;
        Max       = [Math]::max($field.Length, "false".Length);
        Type      = $field.GetType();
      }
    }
  }

  return $fieldMetaData;
}

function Get-InverseSubString {
  <#
  .NAME
    Get-InverseSubString
 
  .SYNOPSIS
    Performs the opposite of [string]::Substring.
 
  .DESCRIPTION
    Returns the remainder of that part of the substring denoted by the $StartIndex
  $Length.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Length
    The number of characters in the sub-string.
 
  .PARAMETER Marker
    A character used to mark the position of the sub-string. If the client specifies
  a marker, then this marker is inserted between the head and the tail.
 
  .PARAMETER Source
    The source string
 
  .PARAMETER Split
    When getting the inverse sub-string there are two elements that are returned,
  the head (prior to sub-string) and the tail, what comes after the sub-string.
    This switch indicates whether the function returns the head and tail as separate
  entities in an array, or should simply return the tail appended to the head.
 
  .PARAMETER StartIndex
    The index of sub-string.
 
  #>

  param(
    [Parameter(Position = 0, Mandatory)]
    [string]$Source,

    [Parameter()]
    [ValidateScript( { $_ -lt $Source.Length })]
    [int]$StartIndex = 0,

    [Parameter()]
    [ValidateScript( { $_ -le ($Source.Length - $StartIndex ) })]
    [int]$Length = 0,

    [Parameter()]
    [switch]$Split,

    [Parameter()]
    [char]$Marker
  )

  $result = if ($StartIndex -eq 0) {
    $PSBoundParameters.ContainsKey('Marker') `
      ? $($Marker + $Source.SubString($Length)) : $Source.SubString($Length);
  }
  else {
    [string]$head = $Source.SubString(0, $StartIndex);

    if ($PSBoundParameters.ContainsKey('Marker')) {
      $head += $Marker;
    }

    [string]$tail = $Source.SubString($StartIndex + $Length);
    ($Split.ToBool()) ? ($head, $tail) : ($head + $tail);
  }
  $result;
}

function Get-IsLocked {
  <#
  .NAME
    Get-IsLocked
 
  .SYNOPSIS
    Utility function to determine whether the environment variable specified
  denotes that it is set to $true to indicate the associated function is in a locked
  state.
 
  .DESCRIPTION
    Returns a boolean indicating the 'locked' status of the associated functionality.
  Eg, for the Rename-Many command, a user can only use it for real when it has been
  unlocked by setting it's associated environment variable 'LOOPZ_REMY_LOCKED' to $false.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Variable
    The environment variable to check.
 
  #>

  [OutputType([boolean])]
  param(
   [Parameter(Mandatory)]
   [string]$Variable
  )

  [string]$lockedEnv = Get-EnvironmentVariable $Variable;
  [boolean]$locked = ([string]::IsNullOrEmpty($lockedEnv) -or
    (-not([string]::IsNullOrEmpty($lockedEnv)) -and
      ($lockedEnv -eq [boolean]::TrueString)));

  return $locked;
}

function Get-LargestLength {
  <#
  .NAME
    Get-LargestLength
 
  .SYNOPSIS
    Get the size of the largest string item in the collection.
 
  .DESCRIPTION
    Get the size of the largest string item in the collection.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Data
    Hashtable containing the table data.
 
  .PARAMETER items
    The source collection to get largest length of.
  #>

  param(
    [string[]]$items
  )
  [int]$largest = 0;
  if ($items -and $items.Count -gt 0) {
    foreach ($i in $items) {
      if (-not([string]::IsNullOrEmpty($i)) -and $($i.Length -gt $largest)) {
        $largest = $i.Length;
      }
    }
  }

  return $largest;
}

function Get-PartitionedPcoHash {
  <#
  .NAME
    Get-PartitionedPcoHash
 
  .SYNOPSIS
    Partitions a hash of PSCustomObject (Pco)s by a specified field name.
 
  .DESCRIPTION
    Given a hashtable whose values are PSCustomObjects, will return a hashtable of
  hashtables, keyed by the field specified. This effectively re-groups the hashtable
  entries based on a custom field. The first level hash in the result is keyed,
  by the field specified. The second level has is the original hash key. So
  given the original hash [ORIGINAL-KEY]=>[PSCustomObject], after partitioning,
  the same PSCustomObject can be accessed, via 2 steps $outputHash[$Field][ORIGINAL-KEY].
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Field
    The name of the field to partition by
 
  .PARAMETER Hash
    The input hashtable to partition
  #>

  param(
    [Parameter(Mandatory)]
    [hashtable]$Hash,

    [Parameter(Mandatory)]
    [string]$Field
  )

  [Hashtable]$partitioned = @{}

  $Hash.GetEnumerator() | ForEach-Object {

    if ($_.Value -is [PSCustomObject]) {
      [string]$partitionKey = (($_.Value.$Field) -is [string]) ? ($_.Value.$Field).Trim() : ($_.Value.$Field);

      if (-not([string]::IsNullOrEmpty($partitionKey))) {
        [hashtable]$partition = if ($partitioned.ContainsKey($partitionKey)) {
          $partitioned[$partitionKey];
        }
        else {
          @{}
        }
        $partition[$_.Key] = $_.Value;
        $partitioned[$partitionKey] = $partition;
      }
      else {
        Write-Debug "WARNING: Get-PartitionedPcoHash field: '$Field' not present on object for key '$($_.Key)'";
      }
    }
    else {
      throw [System.Management.Automation.MethodInvocationException]::new(
        "Get-PartitionedPcoHash invoked with hash whose value for key '$($_.Key)' is not a PSCustomObject");
    }
  }

  return $partitioned;
}

function Get-PlatformName {
  <#
  .NAME
    Get-PlatformName
 
  .SYNOPSIS
    Get the name of the operating system.
 
  .DESCRIPTION
    There are multiple ways to get the OS type in PowerShell but they are convoluted
  and can return an unfriendly name such as 'Win32NT'. This command simply returns
  'windows', 'linux' or 'mac', simples! This function is typically used alongside
  Invoke-ByPlatform and Resolve-ByPlatform.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  #>

  $result = if ($IsWindows) {
    'windows';
  } elseif ($IsLinux) {
    'linux';
  } elseif ($IsMacOS) {
    'mac';
  } else {
    [string]::Empty;
  }

  $result;
}

function Get-PsObjectField {
  <#
  .NAME
    Get-PsObjectField
 
  .SYNOPSIS
    Simplifies getting the value of a field from a PSCustomObject.
 
  .DESCRIPTION
    Returns the value of the specified field. If the field is missing, then
  the default is returned.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Default
    Default value to return if the $Field doesn't exist on the $Object.
 
  .PARAMETER Field
    The field to get value of.
 
  .PARAMETER Object
    The object to get the field value from.
 
  #>

  param(
    [Parameter(Position = 0, Mandatory)]
    [PSCustomObject]$Object,

    [Parameter(Position = 1, Mandatory)]
    [string]$Field,

    [Parameter(Position = 2)]
    $Default = $null
  )

  ($Object.psobject.properties.match($Field) -and ($null -ne $Object.$Field)) ? ($Object.$Field) : $Default;
}

function Get-UniqueCrossPairs {
  <#
  .NAME
    Get-UniqueCrossPairs
 
  .SYNOPSIS
    Given 2 string arrays, returns an array of PSCustomObjects, containing
  First and Second properties. The result is a list of all unique pair combinations
  of the 2 input sequences.
 
  .DESCRIPTION
    Effectively, the result is a matrix with the first collection defining 1 axis
  and the other defining the other axis. Pairs where both elements are the same are
  omitted.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER First
    First string array to compare
 
  .PARAMETER Second
    The other string array to compare
 
  .EXAMPLE 1
  Get-UniqueCrossPairs -first a,b,c -second a,c,d
 
  Returns
 
  First Second
  ----- ------
  a c
  a d
  b a
  b c
  b d
  c d
 
  .EXAMPLE 2
  Get-UniqueCrossPairs -first a,b,c -second d
 
  Returns
 
  First Second
  ----- ------
  d a
  d b
  d c
 
  #>

  [OutputType([PSCustomObject[]])]
  param(
    [Parameter(Mandatory, Position = 0)]
    [string[]]$First,

    [Parameter(Position = 1)]
    [string[]]$Second
  )
  function get-pairsWithItem {
    [OutputType([PSCustomObject[]])]
    param(
      [Parameter(Mandatory, Position = 0)]
      [string]$Item,

      [Parameter(Mandatory, Position = 1)]
      [string[]]$Others
    )

    if ($Others.Count -gt 0) {
      [PSCustomObject[]]$pairs = foreach ($o in $Others) {
        [PSCustomObject]@{
          First  = $Item;
          Second = $o;
        }
      }
    }
    $pairs;
  }

  [string[]]$firstCollection = $First.Clone() | Sort-Object -Unique;
  [string[]]$secondCollection = $PSBoundParameters.ContainsKey('Second') ? `
  $($Second.Clone() | Sort-Object -Unique) : $firstCollection.Clone();

  [int]$firstCount = $firstCollection.Count;
  [int]$secondCount = $secondCollection.Count;

  if (($firstCount -eq 0) -or ($secondCount -eq 0)) {
    return @();
  }

  [PSCustomObject[]]$result = if ($firstCount -eq 1) {
    # 1xN
    #
    get-pairsWithItem -Item $firstCollection[0] -Others $($secondCollection -ne $firstCollection[0]);
  }
  elseif ($secondCount -eq 1) {
    # Nx1
    #
    get-pairsWithItem -Item $secondCollection[0] -Others $($firstCollection -ne $secondCollection[0]);
  }
  else {
    # AxB
    #
    [hashtable]$reflections = @{}
    foreach ($f in $firstCollection) {
      foreach ($s in $secondCollection) {
        if ($f -ne $s) {
          if (-not($reflections.ContainsKey($("$f->$s")))) {
            [PSCustomObject]@{
              First  = $f;
              Second = $s;
            }

            # Now record the reflection and ensure we don't add it again if we encounter it
            #
            $reflections[$("$s->$f")] = $true;
          }
        }
      }
    }
  }

  return $result;
}

function Invoke-ByPlatform {
  <#
  .NAME
    Invoke-ByPlatform
 
  .SYNOPSIS
    Given a hashtable, invokes the function/script-block whose corresponding key matches
  the operating system name as returned by Get-PlatformName.
 
  .DESCRIPTION
    Provides a way to provide OS specific functionality. Returns $null if the $Hash does
  not contain an entry corresponding to the current platform.
    (Doesn't support invoking a function with named parameters; PowerShell doesn't currently
  support this, not even via splatting, if this changes, this will be implemented.)
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Hash
    A hashtable object whose keys are values that can be returned by Get-PlatformName. The
  values are of type PSCustomObject and can contain the following properties:
  + FnInfo: A FunctionInfo instance. This can be obtained from an existing function by
  invoking Get-Command -Name <function-name>
  + Positional: an array of positional parameter values
 
  .EXAMPLE 1
 
  function invoke-winFn {
    param(
      [string]$name,
      [string]$colour
    )
 
    "win: Name:$name, Colour:$colour";
  }
 
  [hashtable]$script:platformsPositional = @{
    'windows' = [PSCustomObject]@{
      FnInfo = Get-Command -Name invoke-winFn -CommandType Function;
      Positional = @('cherry', 'red');
    };
    'linux' = [PSCustomObject]@{
      FnInfo = Get-Command -Name invoke-linuxFn -CommandType Function;
      Positional = @('grass', 'green');
    };
    'mac' = [PSCustomObject]@{
      FnInfo = Get-Command -Name invoke-macFn -CommandType Function;
      Positional = @('lagoon', 'blue');
    };
  }
  Invoke-ByPlatform -Hash $platformsPositional;
 
  On windows, Returns
 
  'win: Name:cherry, Colour:red'
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSPossibleIncorrectUsageOfAssignmentOperator", "")]
  param(
    [Parameter()]
    [hashtable]$Hash
  )

  $result = $null;
  [string]$platform = Get-PlatformName;

  [PSCustomObject]$invokeInfo = if ($Hash.ContainsKey($platform)) {
    $Hash[$platform];
  }
  elseif ($Hash.ContainsKey('default')) {
    $Hash['default'];
  }
  else {
    Write-Error "!!!!!! Missing platform: '$platform' (and no default available)" -ErrorAction Continue;
    $null;
  }

  if ($invokeInfo -and $invokeInfo.FnInfo) {
    if ($invokeInfo.psobject.properties.match('Positional') -and ($null -ne $invokeInfo.Positional)) {
      [array]$positional = $invokeInfo.Positional;

      if ([scriptblock]$block = $invokeInfo.FnInfo.ScriptBlock) {
        $result = $block.InvokeReturnAsIs($positional);
      }
      else {
        Write-Error $("ScriptBlock for function: '$($invokeInfo.FnInfo.Name)', ('$platform': platform) is missing") `
          -ErrorAction Continue;
      }
    }
    elseif ($invokeInfo.psobject.properties.match('Named') -and $invokeInfo.Named) {
      [hashtable]$named = $invokeInfo.Named;

      $result = & $invokeInfo.FnInfo.Name @named;
    }
    else {
      Write-Error $("Missing Positional/Named: '$($invokeInfo.FnInfo.Name)', ('$platform': platform)") `
        -ErrorAction Continue;
    }
  }

  return $result;
}

function New-DryRunner {
  <#
  .NAME
    New-DryRunner
 
  .SYNOPSIS
    Dry-Runner factory function
 
  .DESCRIPTION
    The Dry-Runner is used by the Show-InvokeReport command. The DryRunner can
  be used in unit-tests to ensure that expected parameters can be used to
  invoke the function without causing errors. In the unit tests, the client just needs
  to instantiate the DryRunner (using this function) then pass in an expected list
  of parameters to the Resolve method. The test case can review the result parameter
  set(s) and assert as appropriate. (Actually, a developer can also use the
  RuleController class in unit tests to check that commands do not violate the
  parameter set rules.)
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER CommandName
    The name of the command to get DryRunner instance for
 
  .PARAMETER Scribbler
    The Krayola scribbler instance used to manage rendering to console
 
  .PARAMETER Signals
    The signals hashtable collection
  #>

  param(
    [Parameter()]
    [string]$CommandName,

    [Parameter()]
    [Hashtable]$Signals = $(Get-Signals),

    [Parameter()]
    [Scribbler]$Scribbler
  )

  [System.Management.Automation.CommandInfo]$commandInfo = Get-Command $commandName;
  [syntax]$syntax = New-Syntax -CommandName $commandName -Signals $Signals -Scribbler $Scribbler;
  [RuleController]$controller = [RuleController]::new($commandInfo);
  [PSCustomObject]$runnerInfo = @{
    CommonParamSet = $syntax.CommonParamSet;
  }
  return [DryRunner]::new($controller, $runnerInfo);
}

function New-Syntax {
  <#
  .NAME
    New-Syntax
 
  .SYNOPSIS
    Get a new 'Syntax' object for a command.
 
  .DESCRIPTION
    The Syntax instance is a supporting class for the parameter set tools. It contains
  various formatters, string definitions and utility functionality. The primary feature
  it contains is that relating to the colouring in of the standard syntax statement
  that is derived from a commands parameter set.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER CommandName
    The name of the command to get syntax instance for
 
  .PARAMETER Scheme
    The hashtable syntax specific scheme instance
 
  .PARAMETER Scribbler
    The Krayola scribbler instance used to manage rendering to console
 
  .PARAMETER Signals
    The signals hashtable collection
  #>

  param(
    [Parameter(Mandatory)]
    [string]$CommandName,

    [Parameter()]
    [hashtable]$Signals = $(Get-Signals),

    [Parameter()]
    [Scribbler]$Scribbler,

    [Parameter()]
    [Hashtable]$Scheme
  )
  if (-not($PSBoundParameters.ContainsKey('Scheme'))) {
    $Scheme = Get-SyntaxScheme -Theme $($Scribbler.Krayon.Theme);
  }
  return [syntax]::new($CommandName, $Signals, $Scribbler, $Scheme);
}

function Register-CommandSignals {
  <#
  .NAME
    Register-CommandSignals
 
  .SYNOPSIS
    A client can use this function to register which signals it uses
  with the signal registry. When the user uses the Show-Signals command,
  they can see which signals a command uses and therefore see the impact
  of defining a custom signal.
 
  .DESCRIPTION
    Stores the list of signals used for a command in the signal registry.
  It is recommended that the client defines an alias for their command then
  registers signals against this more concise alias, rather the the full
  command name. This will reduce the chance of an overflow in the console,
  if too many commands are registered. It is advised that clients invoke
  this for all commands that use signals in the module initialisation code.
  This will mean that when a module is imported, the command's signals are
  registered and will show up in the table displayed by 'Show-Signals'.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Alias
    The name of the command's alias, to register the signals under.
 
  .PARAMETER Signals
    The signals hashtable collection, to validate the UsedSet against;
  should be left to the default.
 
  .PARAMETER UsedSet
    The set of signals that the specified command uses.
 
  .EXAMPLE 1
  Register-CommandSignals -Alias 'xcopy', 'WHAT-IF', 'SOURCE', 'DESTINATION'
 
  #>

  [Alias('rgcos')]
  param(
    [Parameter(Mandatory)]
    [string]$Alias,

    [Parameter(Mandatory)]
    [string[]]$UsedSet,

    [Parameter()]
    [hashtable]$Signals = $(Get-Signals)
  )
  if ($Loopz.SignalRegistry.ContainsKey($Alias)) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      "Register failed; alias: '$Alias' already exists."
    );
  }

  [string[]]$signalKeys = $Signals.PSBase.Keys;

  if (Test-ContainsAll -Super $signalKeys -Sub $UsedSet) {
    $Loopz.SignalRegistry[$Alias] = $UsedSet;
  }
  else {
    throw [System.Management.Automation.MethodInvocationException]::new(
      "Register failed; 1 or more of the defined signals are invalid"
    );
  }
}

function Resolve-ByPlatform {
  <#
  .NAME
    Resolve-ByPlatform
 
  .SYNOPSIS
    Given a hashtable, resolves to the value whose corresponding key matches
  the operating system name as returned by Get-PlatformName.
 
  .DESCRIPTION
    Provides a way to select data depending on the current OS as determined by
  Get-PlatformName.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Hash
    A hashtable object whose keys are values that can be returned by Get-PlatformName. The
  values can be anything.
 
  .EXAMPLE 1
 
  [hashtable]$platforms = @{
    'windows' = 'windows-info';
    'linux' = 'linux-info';
    'mac' = 'mac-info';
  }
  Resolve-ByPlatform -Hash $platforms
 
  .EXAMPLE 2 (With default)
 
  [hashtable]$platforms = @{
    'windows' = 'windows-info';
    'default' = 'default-info';
  }
  Resolve-ByPlatform -Hash $platforms
 
  #>

  param(
    [Parameter()]
    [hashtable]$Hash
  )

  $result = $null;
  [string]$platform = Get-PlatformName;

  if ($Hash.ContainsKey($platform)) {
    $result = $Hash[$platform];
  }
  elseif ($Hash.ContainsKey('default')) {
    $result = $Hash['default'];
  }

  return $result;
}

function Show-InvokeReport {
  <#
  .NAME
    Show-InvokeReport
 
  .SYNOPSIS
    Given a list of parameters, shows which parameter set they resolve to. If they
  don't resolve to a parameter set then this is reported. If the parameters
  resolve to more than one parameter set, then all possible candidates are reported.
  This is a helper function which end users and developers alike can use to determine
  which parameter sets are in play for a given list of parameters. It was built to
  counter the un helpful message one sees when a command is invoked either with
  insufficient or an incorrect combination:
 
  "Parameter set cannot be resolved using the specified named parameters. One or
  more parameters issued cannot be used together or an insufficient number of
  parameters were provided.".
   
  Of course not all error scenarios can be detected, but some are which is better
  than none. This command is a substitute for actually invoking the target command.
  The target command may not be safe to invoke on an ad-hoc basis, so it's safer
  to invoke this command specifying the parameters without their values.
 
  .DESCRIPTION
    If no errors were found with any the parameter sets for this command, then
  the result is simply a message indicating no problems found. If the user wants
  to just get the parameter set info for a command, then they can use command
  Show-ParameterSetInfo instead.
 
    Parameter set violations are defined as rules. The following rules are defined:
  - 'Non Unique Parameter Set': Each parameter set must have at least one unique
  parameter. If possible, make this parameter a mandatory parameter.
  - 'Non Unique Positions': A parameter set that contains multiple positional
  parameters must define unique positions for each parameter. No two positional
  parameters can specify the same position.
  - 'Multiple Claims to Pipeline item': Only one parameter in a set can declare the
  ValueFromPipeline keyword with a value of true.
  - 'In All Parameter Sets By Accident': Defining a parameter with multiple
  'Parameter Blocks', some with and some without a parameter set, is invalid.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Common
    switch to indicate if the standard PowerShell Common parameters show be included
 
  .PARAMETER Name
    The name of the command to show invoke report for. Can be alias or full command name.
 
  .PARAMETER InputObject
    Item(s) from the pipeline. Can be command/alias name of the command, or command/alias
  info obtained via Get-Command.
 
  .PARAMETER Params
    The set of parameter names the command is invoked for. This is like invoking the
  command without specifying the values of the parameters.
 
  .PARAMETER Scribbler
    The Krayola scribbler instance used to manage rendering to console
 
  .PARAMETER Strict
    When specified, will not use Mandatory parameters check to for candidate parameter sets
 
  .INPUTS
    CommandInfo or command name bound to $Name.
 
  .EXAMPLE 1 (CommandInfo via pipeline)
  Get-Command 'Rename-Many' | Show-InvokeReport params underscore, Pattern, Anchor, With
 
    
  Show invoke report for command 'Rename-Many' from its command info
 
  .EXAMPLE 2 (command name via pipeline)
  'Rename-Many' | Show-InvokeReport params underscore, Pattern, Anchor, With
 
  Show invoke report for command 'Rename-Many' from its command info
 
  .EXAMPLE 3 (By Name)
  Show-InvokeReport -Name 'Rename-Many' -params underscore, Pattern, Anchor, With
 
  #>

  [CmdletBinding()]
  [Alias('shire')]
  param(
    [Parameter(ParameterSetName = 'ByName', Mandatory, Position = 0)]
    [ValidateNotNullOrEmpty()]
    [string]$Name,

    [Parameter(ParameterSetName = 'ByPipeline', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [ValidateNotNullOrEmpty()]
    [array[]]$InputObject,

    [Parameter(Mandatory)]
    [string[]]$Params,

    [Parameter()]
    [Scribbler]$Scribbler,

    [Parameter()]
    [switch]$Common,

    [Parameter()]
    [switch]$Strict,

    [Parameter()]
    [switch]$Test
  )

  begin {
    [Krayon]$krayon = Get-Krayon
    [hashtable]$signals = Get-Signals;

    if ($null -eq $Scribbler) {
      $Scribbler = New-Scribbler -Krayon $krayon -Test:$Test.IsPresent;
    }

    [hashtable]$shireParameters = @{
      'Params' = $Params;
      'Common' = $Common.IsPresent;
      'Test'   = $Test.IsPresent;
      'Strict' = $Strict.IsPresent;
    }

    if ($PSBoundParameters.ContainsKey('Scribbler')) {
      $shireParameters['Scribbler'] = $Scribbler;
    }      
  }

  process {
    if (($PSCmdlet.ParameterSetName -eq 'ByName') -or
      (($PSCmdlet.ParameterSetName -eq 'ByPipeline') -and ($_ -is [string]))) {

      if ($PSCmdlet.ParameterSetName -eq 'ByPipeline') {
        Get-Command -Name $_ | Show-InvokeReport @shireParameters;
      }
      else {
        Get-Command -Name $Name | Show-InvokeReport @shireParameters;
      }
    }
    elseif ($_ -is [System.Management.Automation.AliasInfo]) {
      if ($_.ResolvedCommand) {
        $_.ResolvedCommand | Show-InvokeReport @shireParameters;
      }
      else {
        Write-Error "Alias '$_' does not resolve to a command" -ErrorAction Stop;
      }
    }
    else {
      Write-Debug " --- Show-InvokeReport - Command: [$($_.Name)] ---";

      [syntax]$syntax = New-Syntax -CommandName $_.Name -Signals $signals -Scribbler $Scribbler;
      [string]$paramSetSnippet = $syntax.TableOptions.Snippets.ParamSetName;
      [string]$resetSnippet = $syntax.TableOptions.Snippets.Reset;
      [string]$lnSnippet = $syntax.TableOptions.Snippets.Ln;
      [string]$punctSnippet = $syntax.TableOptions.Snippets.Punct;
      [string]$commandSnippet = $syntax.TableOptions.Snippets.Command;
      [string]$hiLightSnippet = $syntax.TableOptions.Snippets.HiLight;
      [RuleController]$controller = [RuleController]::New($_);
      [PSCustomObject]$runnerInfo = [PSCustomObject]@{
        CommonParamSet = $syntax.CommonParamSet;
      }

      [DryRunner]$runner = [DryRunner]::new($controller, $runnerInfo);
      $Scribbler.Scribble($syntax.TitleStmt('Invoke Report', $_.Name));

      [System.Management.Automation.CommandParameterSetInfo[]]$candidateSets = $Strict.IsPresent `
        ? $runner.Resolve($Params) `
        : $runner.Weak($Params);

      [string[]]$candidateNames = $candidateSets.Name
      [string]$candidateNamesCSV = $candidateNames -join ', ';
      [string]$paramsCSV = $Params -join ', ';

      [string]$structuredParamNames = $syntax.QuotedNameStmt($hiLightSnippet);
      [string]$unresolvedStructuredParams = $syntax.NamesRegex.Replace($paramsCSV, $structuredParamNames);

      [string]$commonInvokeFormat = $(
        $lnSnippet + $resetSnippet + ' {0}Command: ' +
        $punctSnippet + '''' + $commandSnippet + $Name + $punctSnippet + '''' +
        $resetSnippet + ' invoked with parameters: ' +
        $unresolvedStructuredParams +
        ' {1}' + $lnSnippet
      );

      [string]$doubleIndent = [string]::new(' ', $syntax.TableOptions.Chrome.Indent * 2);
      [boolean]$showCommon = $Common.IsPresent;

      if ($candidateNames.Length -eq 0) {
        [string]$message = "$($resetSnippet)does not resolve to a parameter set and is therefore invalid.";
        $Scribbler.Scribble(
          $($commonInvokeFormat -f $(Get-FormattedSignal -Name 'INVALID' -EmojiOnly), $message)
        );
      }
      elseif ($candidateNames.Length -eq 1) {
        [string]$message = $(
          "$($lnSnippet)$($doubleIndent)$($punctSnippet)=> $($resetSnippet)resolves to parameter set: " +
          "$($punctSnippet)'$($paramSetSnippet)$($candidateNamesCSV)$($punctSnippet)'"
        );
        [string]$resolvedStructuredParams = $syntax.InvokeWithParamsStmt($candidateSets[0], $params);

        # Colour in resolved parameters
        #
        $commonInvokeFormat = $commonInvokeFormat.Replace($unresolvedStructuredParams,
          $resolvedStructuredParams);

        $Scribbler.Scribble(
          $($commonInvokeFormat -f $(Get-FormattedSignal -Name 'OK-A' -EmojiOnly), $message)
        );

        $_ | Show-ParameterSetInfo -Sets $candidateNames -Scribbler $Scribbler `
          -Common:$showCommon -Test:$Test.IsPresent;
      }
      else {
        [string]$structuredName = $syntax.QuotedNameStmt($paramSetSnippet);
        [string]$compoundStructuredNames = $syntax.NamesRegex.Replace($candidateNamesCSV, $structuredName);
        [string]$message = $(
          "$($lnSnippet)$($doubleIndent)$($punctSnippet)=> $($resetSnippet)resolves to parameter sets: " +
          "$($compoundStructuredNames)"
        );

        $Scribbler.Scribble(
          $($commonInvokeFormat -f $(Get-FormattedSignal -Name 'FAILED-A' -EmojiOnly), $message)
        );

        $_ | Show-ParameterSetInfo -Sets $candidateNames -Scribbler $Scribbler `
          -Common:$showCommon -Test:$Test.IsPresent;
      }

      $Scribbler.Flush();
    }
  }
}

function Show-ParameterSetInfo {
  <#
  .NAME
    Show-ParameterSetInfo
 
  .SYNOPSIS
    Displays information for a commands parameter sets. This includes the standard
  syntax statement associated with each parameter set, but is also coloured in, to help
  readability.
 
  .DESCRIPTION
    If the command does not define parameter sets, then no information is displayed
  apart from a message indicating no parameter sets were found.
 
    One of the issues that a developer can encounter when designing parameter sets for
  a command is making sure that each parameter set includes at least 1 unique parameter
  as per recommendations. This function will greatly help in this regard. For each
  parameter set shown, the table it contains includes a 'Unique' column which shows
  whether a the parameter is unique to that parameter set. This relieves the developer
  from having to figure this out themselves.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Common
    switch to indicate if the standard PowerShell Common parameters should be included
 
  .PARAMETER Name
    The name of the command to show parameter set info report for. Can be alias or full command name.
 
  .PARAMETER InputObject
    Item(s) from the pipeline. Can be command/alias name of the command, or command/alias
  info obtained via Get-Command.
 
  .PARAMETER Scribbler
    The Krayola scribbler instance used to manage rendering to console
 
  .PARAMETER Sets
    A list of parameter sets the output should be restricted to. When not specified, all
  parameter sets are displayed.
 
  .PARAMETER Title
    The text displayed as a title. End user does not have to specify this value. It is useful
  to other client commands that invoke this one, so some context can be added to the display.
 
  .INPUTS
    CommandInfo or command name bound to $Name.
 
  .EXAMPLE 1 (Show all parameter sets, CommandInfo via pipeline)
 
  Get-Command 'Rename-Many' | Show-ParameterSetInfo
 
  .EXAMPLE 2 (Show all parameter sets with Common parameters, command name via pipeline)
 
  'Rename-Many' | Show-ParameterSetInfo -Common
 
  .EXAMPLE 3 (Show specified parameter sets, command name via pipeline)
 
  'Rename-Many' | Show-ParameterSetInfo -Sets MoveToAnchor, UpdateInPlace
 
  .EXAMPLE 4 (By Name)
 
  Show-ParameterSetInfo -Name 'Rename-Many' -Sets MoveToAnchor, UpdateInPlace
 
  #>

  [CmdletBinding()]
  [Alias('ships')]
  param(
    [Parameter(ParameterSetName = 'ByName', Mandatory, Position = 0)]
    [ValidateNotNullOrEmpty()]
    [string]$Name,

    [Parameter(ParameterSetName = 'ByPipeline', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [ValidateNotNullOrEmpty()]
    [array[]]$InputObject,

    [Parameter(Position = 1)]
    [string[]]$Sets,

    [Parameter()]
    [Scribbler]$Scribbler,

    [Parameter()]
    [string]$Title = 'Parameter Set Info',

    [Parameter()]
    [switch]$Common,

    [Parameter()]
    [switch]$Test
  )
  # inspired by Get-CommandDetails function by KirkMunro
  # (https://github.com/PowerShell/PowerShell/issues/8692)
  # https://docs.microsoft.com/en-us/powershell/scripting/developer/cmdlet/cmdlet-parameter-sets?view=powershell-7.1
  #
  begin {
    [Krayon]$krayon = Get-Krayon;
    [hashtable]$signals = Get-Signals;

    if ($null -eq $Scribbler) {
      $Scribbler = New-Scribbler -Krayon $krayon -Test:$Test.IsPresent;
    }

    [hashtable]$shipsParameters = @{
      'Title'  = $Title;
      'Common' = $Common.IsPresent;
      'Test'   = $Test.IsPresent;
    }

    if ($PSBoundParameters.ContainsKey('Sets')) {
      $shipsParameters['Sets'] = $Sets;
    }

    if ($PSBoundParameters.ContainsKey('Scribbler')) {
      $shipsParameters['Scribbler'] = $Scribbler;
    }
  }

  process {
    if (($PSCmdlet.ParameterSetName -eq 'ByName') -or
      (($PSCmdlet.ParameterSetName -eq 'ByPipeline') -and ($_ -is [string]))) {

      if ($PSCmdlet.ParameterSetName -eq 'ByPipeline') {
        Get-Command -Name $_ | Show-ParameterSetInfo @shipsParameters;
      }
      else {
        Get-Command -Name $Name | Show-ParameterSetInfo @shipsParameters;
      }
    }
    elseif ($_ -is [System.Management.Automation.AliasInfo]) {
      if ($_.ResolvedCommand) {
        $_.ResolvedCommand | Show-ParameterSetInfo @shipsParameters;
      }
      else {
        Write-Error "Alias '$_' does not resolve to a command" -ErrorAction Stop;
      }
    }
    else {
      Write-Debug " --- Show-ParameterSetInfo - Command: [$($_.Name)] ---";
      [syntax]$syntax = New-Syntax -CommandName $_.Name -Signals $signals -Scribbler $Scribbler;

      [string]$commandSnippet = $syntax.TableOptions.Custom.Snippets.Command;
      [string]$resetSnippet = $syntax.TableOptions.Snippets.Reset;
      [string]$lnSnippet = $syntax.TableOptions.Snippets.Ln;
      $Scribbler.Scribble($syntax.TitleStmt($Title, $_.Name));

      if ($Common) {
        $syntax.TableOptions.Custom.IncludeCommon = $true;
      }

      # Since we're inside a process block $_ refers to a CommandInfo (the result of get-command) and
      # one property is ParameterSets.
      #
      [string]$structuredSummaryStmt = if ($_.ParameterSets.Count -gt 0) {
        [int]$total = $_.ParameterSets.Count;
        [int]$count = 0;

        foreach ($parameterSet in $_.ParameterSets) {
          [boolean]$include = (-not($PSBoundParameters.ContainsKey('Sets')) -or `
            ($PSBoundParameters.ContainsKey('Sets') -and ($Sets -contains $parameterSet.Name)))

          if ($include) {
            [hashtable]$fieldMetaData, [hashtable]$headers, [hashtable]$tableContent = $(
              get-ParameterSetTableData -CommandInfo $_ -ParamSet $parameterSet -Syntax $syntax
            );

            if (-not($($null -eq $fieldMetaData)) -and ($fieldMetaData.PSBase.Keys.Count -gt 0)) {
              [string]$structuredParamSetStmt = $syntax.ParamSetStmt($_, $parameterSet);
              [string]$structuredSyntax = $syntax.SyntaxStmt($parameterSet);

              $Scribbler.Scribble($(
                  "$($lnSnippet)" +
                  "$($structuredParamSetStmt)$($lnSnippet)$($structuredSyntax)$($lnSnippet)" +
                  "$($lnSnippet)"
                ));

              Show-AsTable -MetaData $fieldMetaData -Headers $headers -Table $tableContent `
                -Scribbler $Scribbler -Options $syntax.TableOptions -Render $syntax.RenderCell;

              $count++;
            }
            else {
              $total = 0;
            }
          }
        } # foreach
        $Scribbler.Scribble("$($lnSnippet)");

        ($total -gt 0) `
          ? "Command: $($commandSnippet)$($Name)$($resetSnippet); Showed $count of $total parameter set(s)." `
          : "Command: $($commandSnippet)$($Name)$($resetSnippet) contains no parameter sets!";
      }
      else {
        "Command: $($commandSnippet)$($Name)$($resetSnippet) contains no parameter sets!";
      }

      if (-not([string]::IsNullOrEmpty($structuredSummaryStmt))) {
        $Scribbler.Scribble(
          $("$($resetSnippet)$($structuredSummaryStmt)$($lnSnippet)$($lnSnippet)")
        );
      }

      $Scribbler.Flush();
    }
  }
}

function Show-ParameterSetReport {
  <#
  .NAME
    Show-ParameterSetReport
 
  .SYNOPSIS
    Shows a reporting indicating problems with a command's parameter sets.
 
  .DESCRIPTION
    If no errors were found with any the parameter sets for this command, then
  the result is simply a message indicating no problems found. If the user wants
  to just get the parameter set info for a command, then they can use command
  Show-ParameterSetInfo instead.
 
    Parameter set violations are defined as rules. The following rules are defined:
  - 'Non Unique Parameter Set': Each parameter set must have at least one unique
  parameter. If possible, make this parameter a mandatory parameter.
  - 'Non Unique Positions': A parameter set that contains multiple positional
  parameters must define unique positions for each parameter. No two positional
  parameters can specify the same position.
  - 'Multiple Claims to Pipeline item': Only one parameter in a set can declare the
  ValueFromPipeline keyword with a value of true.
  - 'In All Parameter Sets By Accident': Defining a parameter with multiple
  'Parameter Blocks', some with and some without a parameter set, is invalid.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Name
    The name of the command to show parameter set report for. Can be alias or full command name.
 
  .PARAMETER InputObject
    Item(s) from the pipeline. Can be command/alias name of the command, or command/alias
  info obtained via Get-Command.
 
  .PARAMETER Scribbler
    The Krayola scribbler instance used to manage rendering to console
 
  .INPUTS
    CommandInfo or command name bound to $Name.
 
  .EXAMPLE 1 (CommandInfo via pipeline)
  Get Command Rename-Many | Show-ParameterSetReport
 
  .EXAMPLE 2 (command name via pipeline)
  'Rename-Many' | Show-ParameterSetReport
 
  .EXAMPLE 3 (By Name)
  Show-ParameterSetReport -Name 'Rename-Many'
 
  #>

  [CmdletBinding()]
  [Alias('sharp')]
  param(
    [Parameter(ParameterSetName = 'ByName', Mandatory, Position = 0)]
    [ValidateNotNullOrEmpty()]
    [string]$Name,

    [Parameter(ParameterSetName = 'ByPipeline', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [ValidateNotNullOrEmpty()]
    [array[]]$InputObject,

    [Parameter()]
    [Scribbler]$Scribbler,

    [Parameter()]
    [switch]$Test
  )

  begin {
    [Krayon]$krayon = Get-Krayon
    [hashtable]$signals = Get-Signals;
    if ($null -eq $Scribbler) {
      $Scribbler = New-Scribbler -Krayon $krayon -Test:$Test.IsPresent;
    }

    [hashtable]$sharpParameters = @{
      'Test' = $Test.IsPresent;
    }

    if ($PSBoundParameters.ContainsKey('Scribbler')) {
      $sharpParameters['Scribbler'] = $Scribbler;
    }
  }

  process {
    # Reminder: $_ is commandInfo
    #
    if (($PSCmdlet.ParameterSetName -eq 'ByName') -or
      (($PSCmdlet.ParameterSetName -eq 'ByPipeline') -and ($_ -is [string]))) {

      if ($PSCmdlet.ParameterSetName -eq 'ByPipeline') {
        Get-Command -Name $_ | Show-ParameterSetReport @sharpParameters;
      }
      else {
        Get-Command -Name $Name | Show-ParameterSetReport @sharpParameters;
      }
    }
    elseif ($_ -is [System.Management.Automation.AliasInfo]) {
      if ($_.ResolvedCommand) {
        $_.ResolvedCommand | Show-ParameterSetReport @sharpParameters;
      }
      else {
        Write-Error "Alias '$_' does not resolve to a command" -ErrorAction Stop;
      }
    }
    else {
      Write-Debug " --- Show-ParameterSetReport - Command: [$($_.Name)] ---";

      [syntax]$syntax = New-Syntax -CommandName $_.Name -Signals $signals -Scribbler $Scribbler;
      [RuleController]$controller = [RuleController]::New($_);

      $Scribbler.Scribble($syntax.TitleStmt('Parameter Set Violations Report', $_.Name));

      [PSCustomObject]$queryInfo = [PSCustomObject]@{
        CommandInfo = $_;
        Syntax      = $syntax;
        Scribbler   = $Scribbler;
      }
      $controller.ReportAll($queryInfo);

      $Scribbler.Flush();
    }
  }
}

function Test-ContainsAll {
  <#
  .NAME
    Test-ContainsAll
 
  .SYNOPSIS
    Given two sequences of strings, determines if first contains all elements
  of the other.
 
  .DESCRIPTION
    Is the first set a super set of the second.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Super
    The super set (First)
 
  .PARAMETER Sub
    The sub set (Second)
  #>

  [OutputType([boolean])]
  param(
    [Parameter(Mandatory, Position = 0)]
    [string[]]$Super,

    [Parameter(Mandatory, Position = 1)]
    [string[]]$Sub
  )
  [string[]]$containedSet = $Sub | Where-Object { $Super -contains $_ };
  return $containedSet.Length -eq $Sub.Length;
}

function Test-HostSupportsEmojis {
  <#
  .NAME
    Test-HostSupportsEmojis
 
  .SYNOPSIS
    This is a rudimentary function to determine if the host can display emojis. This
  function will be super-ceded when this issue (on microsoft/terminal
  https://github.com/microsoft/terminal/issues/1040) is resolved.
 
  .DESCRIPTION
    There is currently no standard way to determine this. As a crude workaround, this function
  can determine if the host is Windows Terminal and returns true. Fluent Terminal can
  display emojis, but does not render them very gracefully, so the default value
  returned for Fluent is false. Its assumed that hosts on Linux and Mac can support
  the display of emojis, so they return true. If user want to enforce using emojis,
  then they can define LOOPZ_FORCE_EMOJIS in the environment, this will force this
  function to return.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  #>

  [OutputType([boolean])]
  param()

  function Test-WinHostSupportsEmojis {
    [OutputType([boolean])]
    param()

    # Fluent Terminal: $($env:TERM_PROGRAM -eq 'FluentTerminal')
    #
    return $($null -ne $env:WT_SESSION);
  }
  function Test-DefHostSupportsEmojis {
    [OutputType([boolean])]
    param()

    return $false;
  }

  [boolean]$result = if ($null -eq $(Get-EnvironmentVariable -Variable 'LOOPZ_FORCE_EMOJIS')) {
    # Currently, it is not known how well emojis are displayed in a linux console and results
    # so far found on a mac are less than desirable (not because emojis are not supported, but
    # they do not appear to be well aligned and as a result looks slightly scruffy). For this
    # reason, they will be default configured not to use emoji display, although the user if they
    # wish can override this by defining LOOPZ_FORCE_EMOJIS in their environment.
    #
    [hashtable]$supportsEmojis = @{
      'windows' = [PSCustomObject]@{
        FnInfo     = Get-Command -Name Test-WinHostSupportsEmojis -CommandType Function;
        Positional = @();
      };
      'default' = [PSCustomObject]@{
        FnInfo     = Get-Command -Name Test-DefHostSupportsEmojis -CommandType Function;
        Positional = @();
      };
    }

    Invoke-ByPlatform -Hash $supportsEmojis;
  }
  else {
    $true;
  }

  return $result;
}

function Test-Intersect {
  <#
  .NAME
    Test-Intersect
 
  .SYNOPSIS
    Determines if two sets of strings contains any common elements.
 
  .DESCRIPTION
    Essentially asks the question, 'Do the two sets intersect'.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER First
    First collection of strings to compare.
 
  .PARAMETER Second
    Second collection of strings to compare.
  #>

  [OutputType([boolean])]
  param(
    [Parameter(Mandatory, Position = 0)]
    [string[]]$First,

    [Parameter(Mandatory, Position = 1)]
    [string[]]$Second
  )
  return $($First | Where-Object { ($Second -contains $_) })
}

function Test-IsAlreadyAnchoredAt {
  <#
  .NAME
    Test-IsAlreadyAnchoredAt
 
  .SYNOPSIS
    Checks to see if a given pattern is matched at the start or end of an input string.
 
  .DESCRIPTION
    When Rename-Many uses the Start or End switches to move a match to the corresponding location,
  it needs to filter out those entries where the specified occurrence of the Pattern is already
  at the desire location. We can't do this using a synthetic anchored regex using ^ and $, rather
  we must use the origin regex, perform the match and then see where that match resides, by consulting
  the index and length of that match instance.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Source
    The input source
 
  .PARAMETER Expression
    A regex instance to match against
 
  .PARAMETER Occurrence
    Which match occurrence in Expression do we want to check
 
  .PARAMETER Start
    Check match is at the start of the input source
 
  .PARAMETER End
    Check match is at the end of the input source
  #>

  [OutputType([boolean])]
  param(
    [Parameter()]
    [string]$Source,

    [Parameter()]
    [regex]$Expression,

    [Parameter()]
    [string]$Occurrence,

    [Parameter()]
    [switch]$Start,

    [Parameter()]
    [switch]$End
  )

  [hashtable]$parameters = @{
    'Source'       = $Source;
    'PatternRegEx' = $Expression;
    'Occurrence'   = $Occurrence;
  }

  [string]$capturedExpression, $null, `
    [System.Text.RegularExpressions.Match]$expressionMatch = Split-Match @parameters;

  [boolean]$result = if (-not([string]::IsNullOrEmpty($capturedExpression))) {
    if ($Start.IsPresent) {
      # For the Start, its easy to see if the match is already at the start,
      # we just check the match's index being 0.
      #
      $expressionMatch.Index -eq 0;
    }
    elseif ($End.IsPresent) {
      # 012345
      # source = ABCDEA
      # PATTERN = 'A'
      # OCC = 1
      #
      # In the above example, if we wanted to move the first A to the end, we need
      # to see if that occurrence is at the end, NOT does that pattern appear at the
      # end. The old logic, using a synthesized Anchored regex, performed the latter
      # logic and that's why it failed. What we want, is to check that our specific
      # Occurrence is not already at the end, which of course it isn't. We do this,
      # by checking the location of our match.
      #
      $($expressionMatch.Index + $expressionMatch.Length) -eq $Source.Length;
    }
    else {
      $false;
    }
  }
  else {
    $false;
  }

  return $result;
}

function Test-IsFileSystemSafe {
  <#
  .NAME
    Test-IsFileSystemSafe
 
  .SYNOPSIS
    Checks the $Value to see if it contains any file-system un-safe characters.
 
  .DESCRIPTION
    Warning, this function is not comprehensive nor platform specific, but it does not
  intend to be. There are some characters eg /, that are are allowable under mac/linux
  as part of the filename but are not under windows; in this case they are considered
  unsafe for all platforms. This approach is taken because of the likely possibility
  that a file may be copied over from differing file system types.
 
  .LINK
    https://eliziumnet.github.io/Loopz/
 
  .PARAMETER Value
    The string value to check.
  #>

  [OutputType([boolean])]
  param(
    [Parameter()]
    [string]$Value,

    [Parameter()]
    [char[]]$InvalidSet = $Loopz.InvalidCharacterSet
  )
  return ($Value.IndexOfAny($InvalidSet) -eq -1);
}

function Resolve-Error ($ErrorRecord = $Error[0]) {
  $ErrorRecord | Format-List * -Force
  $ErrorRecord.InvocationInfo | Format-List *
  $Exception = $ErrorRecord.Exception
  for ($i = 0; $Exception; $i++, ($Exception = $Exception.InnerException)) {
    "$i" * 80
    $Exception | Format-List * -Force
  }
}
function get-Captures {
  [OutputType([hashtable])]
  param(
    [Parameter(Mandatory)]
    [System.Text.RegularExpressions.Match]$MatchObject
  )

  [hashtable]$captures = @{}
  [System.Text.RegularExpressions.GroupCollection]$groups = $MatchObject.Groups;

  foreach ($key in $groups.Keys) {
    $captures[$key] = $groups[$key];
  }

  return $captures;
}

function initialize-Signals {
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')]
  [OutputType([hashtable])]
  param(
    [Parameter()]
    [hashtable]$Signals = $global:Loopz.DefaultSignals,

    [Parameter()]
    [hashtable]$Overrides = $global:Loopz.OverrideSignals
  )

  [hashtable]$source = $Signals.Clone();
  [hashtable]$withOverrides = Resolve-ByPlatform -Hash $Overrides;

  [boolean]$useEmoji = $(Test-HostSupportsEmojis);

  [hashtable]$resolved = @{}
  [int]$index = $useEmoji ? 1 : 2;

  $source.GetEnumerator() | ForEach-Object {
    $resolved[$_.Key] = New-Pair(@($_.Value[0], $_.Value[$index]));
  }

  $withOverrides.GetEnumerator() | ForEach-Object {
    try {
      $resolved[$_.Key] = New-Pair(@($_.Value[0], $_.Value[$index])); ;
    }
    catch {
      Write-Error "Skipping override signal: '$($_.Key)'";
    }
  }

  return $resolved;
}

function invoke-PostProcessing {
  param(
    [Parameter()]
    [string]$InputSource,

    [Parameter()]
    [PSCustomObject[]]$Rules,

    [Parameter()]
    [hashtable]$signals
  )
  [string]$transformResult = $InputSource;

  [string[]]$appliedSignals = foreach ($rule in $Rules) {
    if ($rule['IsApplicable'].InvokeReturnAsIs($transformResult)) {
      $transformResult = $rule['Transform'].InvokeReturnAsIs($transformResult);
      $rule['Signal'];
    }
  }

  [PSCustomObject]$result = if ($appliedSignals.Count -gt 0) {
    [System.Collections.Generic.List[string]]$labels = [System.Collections.Generic.List[string]]::new()

    [string]$indication = -join $(foreach ($name in $appliedSignals) {
      $labels.Add($signals[$name].Key);
      $signals[$name].Value;
    })
    $indication = "[{0}]" -f $indication;

    [PSCustomObject]@{
      TransformResult = $transformResult;
      Indication      = $indication;
      Signals         = $appliedSignals;
      Label           = 'Post ({0})' -f $($labels -join ', ');
      Modified        = $true;
    }
  }
  else {
    [PSCustomObject]@{
      TransformResult = $InputSource;
      Modified        = $false;
    }
  }

  $result;
}

function rename-FsItem {
  [CmdletBinding(SupportsShouldProcess)]
  param(
    [Parameter()]
    [System.IO.FileSystemInfo]$From,

    [Parameter()]
    [string]$To,

    [Parameter()]
    [AllowNull()]
    [UndoRename]$UndoOperant
  )
  [boolean]$itemIsDirectory = ($From.Attributes -band
    [System.IO.FileAttributes]::Directory) -eq [System.IO.FileAttributes]::Directory;

  [string]$parentPath = $itemIsDirectory ? $From.Parent.FullName : $From.Directory.FullName;
  [string]$destinationPath = Join-Path -Path $parentPath -ChildPath $To;

  if (-not($PSBoundParameters.ContainsKey('WhatIf') -and $PSBoundParameters['WhatIf'])) {
    try {
      [boolean]$differByCaseOnly = $From.Name.ToLower() -eq $To.ToLower();

      if ($differByCaseOnly) {
        # Just doing a double rename to get around the problem of not being able to rename
        # an item unless the case is different
        #
        [string]$tempName = $From.Name + "_";

        Rename-Item -LiteralPath $From.FullName -NewName $tempName -PassThru | `
          Rename-Item -NewName $To;
      }
      else {
        Rename-Item -LiteralPath $From.FullName -NewName $To;
      }

      if ($UndoOperant) {
        [PSCustomObject]$operation = [PSCustomObject]@{
          Directory = $parentPath;
          From      = $From.Name;
          To        = $To;
        }
        Write-Debug "rename-FsItem (Undo Rename) => alert: From: '$($operation.From.Name)', To: '$($operation.To)'";
        $UndoOperant.alert($operation);
      }

      $result = Get-Item -LiteralPath $destinationPath;
    }
    catch [System.IO.IOException] {
      $result = $null;
    }
  }
  else {
    $result = $To;
  }

  return $result;
} # rename-FsItem
  function select-ResolvedFsItem {
    [OutputType([boolean])]
    param(
      [Parameter(Mandatory)]
      [string]$FsItem,

      [Parameter(Mandatory)]
      [AllowEmptyCollection()]
      [string[]]$Filter,

      [Parameter()]
      [switch]$Case
    )

    [boolean]$liked = $false;
    [int]$counter = 0;

    do {
      $liked = $Case.ToBool() `
        ? $FsItem -CLike $Filter[$counter] `
        : $FsItem -Like $Filter[$counter];
      $counter++;
    } while (-not($liked) -and ($counter -lt $Filter.Count));

    $liked;
  }

function test-ValidPatternArrayParam {
  [OutputType([boolean])]
  param(
    [Parameter(Mandatory)]
    [array]$Arg,

    [Parameter()]
    [switch]$AllowWildCard
  )

  [boolean]$result = $Arg -and ($Arg.Count -gt 0) -and ($Arg.Count -lt 2) -and `
    -not([string]::IsNullOrEmpty($Arg[0])) -and (($Arg.Length -eq 1) -or $Arg[1] -ne '*');

  if ($result -and $Arg.Count -gt 1 -and $Arg[1] -eq '*') {
    $result = $AllowWildCard.ToBool();
  }

  $result;
}

function find-DuplicateParamPositions {
  [CmdletBinding()]
  [OutputType([array])]
  param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [System.Management.Automation.CommandInfo]$CommandInfo,

    [Parameter(Mandatory)]
    [Syntax]$Syntax
  )

  [System.Management.Automation.CommandParameterSetInfo[]]$paramSets = $commandInfo.ParameterSets;

  [scriptblock]$paramIsPositional = [scriptblock] {
    [OutputType([boolean])]
    param (
      [Parameter()]
      [PSCustomObject]$row
    )
    return $row.Pos -ne 'named';
  };

  [array]$pods = foreach ($paramSet in $paramSets) {
    $null, $null, [hashtable]$tableContent = `
      get-ParameterSetTableData -CommandInfo $CommandInfo `
      -ParamSet $paramSet -Syntax $Syntax -Where $paramIsPositional;

    # We might encounter a parameter set which does not contain positional parameters,
    # in which case, we should ignore.
    #
    if ($tableContent -and ($tableContent.PSBase.Count -gt 0)) {
      [hashtable]$partitioned = Get-PartitionedPcoHash -Hash $tableContent -Field 'Pos';
      # partitioned is indexed by the Pos value, not 'Pos'
      #
      if ($partitioned.PSBase.Count -gt -0) {
        $partitioned.GetEnumerator() | ForEach-Object {
          [hashtable]$positional = $_.Value;

          if ($positional -and ($positional.PSBase.Count -gt 1)) {
            # found duplicate positions
            #
            [string[]]$params = $($positional.GetEnumerator() | ForEach-Object { $_.Key } | Sort-Object);

            [PSCustomObject]$seed = [PSCustomObject]@{
              ParamSet = $paramSet;
              Params   = $params;
              Number   = $_.Key;
            }

            $seed;
          }
        }
      }
    }
  }

  return ($pods.Count -gt 0) ? $pods : $null;
}

function find-DuplicateParamSets {
  [CmdletBinding()]
  [OutputType([array])]
  param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [System.Management.Automation.CommandInfo]$CommandInfo,

    [Parameter(Mandatory)]
    [Syntax]$Syntax
  )
  [System.Management.Automation.CommandParameterSetInfo[]]$paramSets = $(
    $commandInfo.ParameterSets | Where-Object { $_.Name -ne '__AllParameterSets' }
  );

  [string[]]$paramSetNames = $paramSets.Name; 
  [array]$pods = @()

  [hashtable]$paramSetLookup = @{}
  foreach ($paramSet in $paramSets) {
    $paramSetLookup[$paramSet.Name] = $paramSet;
  }

  if ($paramSetNames -and ($paramSetNames.Count -gt 0)) {
    [PSCustomObject[]]$paramSetPairs = Get-UniqueCrossPairs -First $paramSetNames;

    $pods = foreach ($pair in $paramSetPairs) {
      [System.Management.Automation.CommandParameterSetInfo]$firstParamSet = $paramSetLookup[$pair.First];
      [System.Management.Automation.CommandParameterSetInfo]$secondParamSet = $paramSetLookup[$pair.Second];

      if ($firstParamSet -and $secondParamSet) {
        if (test-AreParamSetsEqual -FirstPsInfo $firstParamSet -SecondPsInfo $secondParamSet -Syntax $Syntax) {
          [PSCustomObject]$seed = [PSCustomObject]@{
            First  = $firstParamSet;
            Second = $secondParamSet;
          }

          $seed;
        }
      }
      else {
        throw "find-DuplicateParamSets: Couldn't recall previously stored parameter set(s). (This should never happen)";
      }
    }
  }

  return ($pods.Count -gt 0) ? $pods : $null;
}

function find-InAllParameterSetsByAccident {
  [CmdletBinding()]
  [OutputType([array])]
  param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [System.Management.Automation.CommandInfo]$CommandInfo,

    [Parameter(Mandatory)]
    [Syntax]$Syntax
  )
  [System.Management.Automation.CommandParameterSetInfo[]]$paramSets = $commandInfo.ParameterSets;
  [System.Collections.Generic.List[PSCustomObject]]$pods = `
    [System.Collections.Generic.List[PSCustomObject]]::new();

  foreach ($paramSet in $paramSets) {
    [System.Management.Automation.CommandParameterInfo[]]$params = $paramSet.Parameters |`
      Where-Object { $_.Name -NotIn $Syntax.CommonParamSet };

    if ($params -and $params.Count -gt 0) {
      [System.Management.Automation.CommandParameterInfo[]]$candidates = $($params | Where-Object {
          ($_.Attributes.ParameterSetName.Count -gt 1) -and
          ($_.Attributes.ParameterSetName -contains [Syntax]::AllParameterSets)
        });

      foreach ($candidate in $candidates) {
        [PSCustomObject]$seed = [PSCustomObject]@{
          Param    = $candidate.Name;
          ParamSet = $paramSet;
        }
        $pods.Add($seed);
      }
    }
  }

  return ($pods.Count -gt 0) ? $pods : $null;
}

function find-MultipleValueFromPipeline {
  [CmdletBinding()]
  [OutputType([array])]
  param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [System.Management.Automation.CommandInfo]$CommandInfo,

    [Parameter(Mandatory)]
    [Syntax]$Syntax
  )

  [System.Management.Automation.CommandParameterSetInfo[]]$paramSets = $commandInfo.ParameterSets;

  [scriptblock]$paramIsValueFromPipeline = [scriptblock] {
    [OutputType([boolean])]
    param (
      [Parameter()]
      [PSCustomObject]$row
    )
    return [boolean]$row.PipeValue;
  };

  [array]$pods = foreach ($paramSet in $paramSets) {
    $null, $null, [hashtable]$tableContent = `
      get-ParameterSetTableData -CommandInfo $CommandInfo `
      -ParamSet $paramSet -Syntax $Syntax -Where $paramIsValueFromPipeline;

    if ($tableContent -and ($tableContent.PSBase.Count -gt 1)) {
      [PSCustomObject]$seed = [PSCustomObject]@{
        ParamSet = $paramSet;
        Params   = $tableContent.PSBase.Keys;
      }
      $seed;
    }
  }

  return ($pods.Count -gt 0) ? $pods : $null;
}

function get-CommandDetail {
  # by KirkMunro (https://github.com/PowerShell/PowerShell/issues/8692)
  [CmdletBinding()]
  param(
    [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [ValidateNotNullOrEmpty()]
    [string[]]
    $Name
  )
  process {
    if ($_ -isnot [System.Management.Automation.CommandInfo]) {
      Get-Command -Name $_ | get-CommandDetail
    }
    else {
      $commandPropDetails = ($_ | Format-List @{Name = 'CommandName'; Expression = { $_.Name } }, CommandType, ImplementingType, Dll, HelpFile | Out-String) -replace '^[\r\n]+|[\r\n]+$'

      $sb = [System.Text.StringBuilder]::new()
      $null = $sb.AppendLine($commandPropDetails)
      $null = $sb.AppendLine()

      foreach ($parameterSet in $_.ParameterSets) {
        $parametersToShow = $parameterSet.Parameters | Where-Object Name -NotIn @('Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'VerboseAction', 'DebugAction', 'ProgressAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'DebugVariable', 'VerboseVariable', 'ProgressVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'WhatIf', 'Confirm')
        $parameterGroups = $parametersToShow.where( { $_.Position -ge 0 }, 'split')
        $parameterGroups[0] = @($parameterGroups[0] | Sort-Object -Property Position)
        $parametersToShow = $parameterGroups[0] + $parameterGroups[1]
        $parameterDetails = ($parametersToShow `
          | Select-Object -Property @(
            'Name'
            @{Name = 'Type'; Expression = { $_.ParameterType.Name } }
            @{Name = 'Mandatory'; Expression = { $_.IsMandatory } }
            @{Name = 'Pos'; Expression = { if ($_.Position -eq [int]::MinValue) { 'named' } else { $_.Position } } }
            @{Name = 'PipeValue'; Expression = { $_.ValueFromPipeline } }
            @{Name = 'PipeName'; Expression = { $_.ValueFromPipelineByPropertyName } }
            @{Name = 'Alias'; Expression = { $_.Aliases -join ',' } }
          ) `
          | Format-Table -Property Name, Type, Mandatory, Pos, PipeValue, PipeName, Alias `
          | Out-String) -replace '^[\r\n]+|[\r\n]+$'

        $null = $sb.AppendLine("Parameter Set: $($ParameterSet.Name)$(if ($_.DefaultParameterSet -eq $ParameterSet.Name) {' (Default)'})")
        $null = $sb.AppendLine()
        $null = $sb.Append("Syntax: $($_.Name) ")
        $null = $sb.AppendLine($parameterSet.ToString())
        $null = $sb.AppendLine()
        $null = $sb.AppendLine('Parameters:')
        $null = $sb.AppendLine($parameterDetails)
        $null = $sb.AppendLine()
      }
      $sb.ToString()
    }
  }
}

function get-ParameterSetTableData {
  # meta, headers, content
  #
  [OutputType([array])]
  param(
    [Parameter()]
    [System.Management.Automation.CommandInfo]$CommandInfo,

    [Parameter()]
    [System.Management.Automation.CommandParameterSetInfo]$ParamSet,

    [Parameter()]
    [Syntax]$Syntax,

    [Parameter()]
    [scriptblock]$Where = $([scriptblock] {
        [OutputType([boolean])]
        param (
          [Parameter()]
          [PSCustomObject]$row
        )
        return $true;
      })
  )

  $parametersToShow = $Syntax.TableOptions.Custom.IncludeCommon `
    ? $ParamSet.Parameters : $($ParamSet.Parameters | Where-Object Name -NotIn $Syntax.CommonParamSet);

  [PSCustomObject[]]$resultSet = ($parametersToShow `
    | Select-Object -Property @(
      'Name'
      @{Name = 'Type'; Expression = { $_.ParameterType.Name }; }
      @{Name = 'Mandatory'; Expression = { $_.IsMandatory } }
      @{Name = 'Pos'; Expression = { if ($_.Position -eq [int]::MinValue) { 'named' } else { $_.Position } } }
      @{Name = 'PipeValue'; Expression = { $_.ValueFromPipeline } }
      @{Name = 'PipeName'; Expression = { $_.ValueFromPipelineByPropertyName } }
      @{Name = 'Alias'; Expression = { $_.Aliases -join ',' } }
      @{Name = 'Unique'; Expression = { test-IsParameterUnique -Name $_.Name -CommandInfo $CommandInfo } }
    ));

  [array]$result = if (-not($($null -eq $resultSet)) -and ($resultSet.Count -gt 0)) {
    $resultSet = $resultSet | Where-Object { $Where.InvokeReturnAsIs($_); }

    if ($resultSet) {
      [hashtable]$fieldMetaData = Get-FieldMetaData -Data $resultSet;
      $Syntax.TableOptions.Custom.ParameterSetInfo = $ParamSet;

      [hashtable]$headers, [hashtable]$tableContent = Get-AsTable -MetaData $fieldMetaData `
        -TableData $resultSet -Options $Syntax.TableOptions;

      @($fieldMetaData, $headers, $tableContent);
    }
    else {
      @()
    }
  }
  else {
    @()
  }

  return $result;
}

function test-AreParamSetsEqual {
  [OutputType([boolean])]
  param(
    [Parameter(Mandatory)]
    [System.Management.Automation.CommandParameterSetInfo]$FirstPsInfo,

    [Parameter(Mandatory)]
    [System.Management.Automation.CommandParameterSetInfo]$SecondPsInfo,

    [Parameter(Mandatory)]
    [Syntax]$syntax
  )

  if ($FirstPsInfo -and $SecondPsInfo) {
    [array]$firstPsParams = $FirstPsInfo.Parameters | Where-Object Name -NotIn $Syntax.CommonParamSet;
    [array]$secondPsParams = $SecondPsInfo.Parameters | Where-Object Name -NotIn $Syntax.CommonParamSet;

    [string[]]$paramNamesFirst = $($firstPsParams).Name | Sort-Object;
    [string[]]$paramNamesSecond = $($secondPsParams).Name | Sort-Object;

    return $($null -eq $(Compare-Object -ReferenceObject $paramNamesFirst -DifferenceObject $paramNamesSecond));
  }
}

function test-IsParameterUnique {
  param(
    [Parameter(Mandatory)]
    [string]$Name,

    [Parameter(Mandatory)]
    [System.Management.Automation.CommandInfo]$CommandInfo
  )
  [System.Management.Automation.ParameterMetadata]$parameterMetaData = `
    $CommandInfo.Parameters?[$Name];

  [boolean]$unique = if ($null -ne $parameterMetaData) {
    if ($parameterMetaData.ParameterSets.PSBase.ContainsKey('__AllParameterSets')) {
      $false;
    }
    else {
      $($parameterMetaData.ParameterSets.PSBase.Count -le 1) 
    }
  }
  else {
    $false;
  }
  return $unique;
}

<#
  .NAME
    BoundEntity
 
  .SYNOPSIS
    Abstract Entity base class. Entities are used to tie together various pieces
  of information into a single bundle. This ensures that for a particular item
  the logic and info is centralised and handled in a consistent manner. The various
  concepts that are handled by an entity are
 
  * handle items that needs some kind of transformation (eg, regex need to be
  constructed via New-RegularExpression)
  * populating exchange
  * creation of signal
  * formulation and validation of formatters
  * container selection
 
  .DESCRIPTION
    Populates Keys into exchange.
  There are 2 types of entity, primary and related. Primary entities should have a
  boolean Activate property. This denotes whether the entity is created, actioned
  and stored in the bootstrap. Relation entities are dependent on either other
  primary or related entities. Instead of a boolean Activate property, they should
  have an Activator predicate property which is a script block that returns a boolean.
  Typically, the Activator determines it's activated state by consulting other
  entities, returning true if it is active, false otherwise.
#>

class BoundEntity {
  [PSCustomObject]$Spec;
  [boolean]$Executed = $false;

  BoundEntity([PSCustomObject]$spec) {
    $this.Spec = $spec;
  }

  # Validation should only be performed prior to execution, not inside the constructor
  # because we want be sure we're a fully constructed object
  #
  [void] RequireAny([string[]]$fields) {
    [boolean]$found = $false;
    [int]$index = 0;

    while (-not($found) -and ($index -lt $fields.Count)) {
      [string]$current = $fields[$index];
      if ($this.Spec.psobject.properties.match($current).Count) {
        $found = $true;
      }
      $index++;

    }

    if (-not($found)) {
      [string]$csv = $fields -join ', ';

      throw [System.Management.Automation.MethodInvocationException]::new(
        "BoundEntity.RequireAny spec ['$($this.Spec.Name)'] does not contain any of: '$csv'");
    }
  }

  [void] RequireOnlyOne([string[]]$fields) {
    [int]$index = 0;

    [string[]]$found = foreach ($f in $fields) {
      [string]$current = $fields[$index];
      if ($this.Spec.psobject.properties.match($current).Count) {
        $current;
      }

      $index++;
    }

    [string]$csv = $fields -join ', ';
    if ($found.Count -eq 0) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        "BoundEntity.RequireOnlyOne spec ['$($this.Spec.Name)'] does not contain any of: '$csv'");
    }

    if ($found.Count -gt 1) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        "BoundEntity.RequireOnlyOne spec ['$($this.Spec.Name)'] contains more than one of: '$csv'");
    }
  }

  [void] Require([string]$field) {
    if (-not($this.Spec.psobject.properties.match($field).Count)) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        "BoundEntity.Require ['$($this.Spec.Name)'] is missing required field: '$field'");
    }
  }

  [void] RequireAll([string[]]$mandatory) {
    if ($mandatory.Count -gt 0) {
      foreach ($item in $mandatory) {
        $this.Require($item);
      }
    }
  }

  [void] Exec ([BootStrap]$bootstrap) {
    $this.RequireOnlyOne(@('Activate', 'Activator'));
    $this.RequireAll(@('Name', 'SpecType'));

    # Populate Keys
    #
    if ($(Get-PsObjectField -Object $this.Spec -Field 'Keys') -and ($this.Spec.Keys.PSBase.Count -gt 0)) {
      $this.Spec.Keys.GetEnumerator() | ForEach-Object {
        if ($bootstrap.Exchange.ContainsKey($_.Key)) {
          throw [System.Management.Automation.MethodInvocationException]::new(
            "BoundEntity.Exec ['$($this.Spec.Name)'] Key: '$($_.Key)' already exists");
        }
        Write-Debug "BoundEntity.Exec ['$($this.Spec.Name)']; Key: '$($_.Key)', Value: '$($_.Value)'";
        $bootstrap.Exchange[$_.Key] = $_.Value;
      }
    }

    $this.Executed = $true;
  }
}

<#
  .NAME
    SimpleEntity
 
  .SYNOPSIS
    Simple item that does not need any transformation of the value and is not
  represented by a signal.
 
  .DESCRIPTION
    Populates Keys into exchange. A simple entity can be used if all that is required
  is to populate an exchange entry (via Keys); this is why the Value member is
  optional.
 
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Name (mandatory) -> identifies the entity
  * SpecType (mandatory) -> 'simple'
  * Value (optional) -> typically the value of a parameter, but can be anything.
  * Keys (optional) -> Collection of key/value pairs to be inserted into exchange.
#>

class SimpleEntity : BoundEntity {
  SimpleEntity([PSCustomObject]$spec): base($spec) {
    $this.Spec = $spec;
  }
}

<#
  .NAME
    SignalEntity
 
  .SYNOPSIS
    For signalled entities.
 
  .DESCRIPTION
    Manages signal related functionality. This entity manages its own internal
  signal value in addition to the client specified signal value. This is because
  derived classes such as RegexEntity may want to override the signal value, but
  we should not scribble over what ever the client has defined, so we write to
  an internal value instead.
 
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Name (mandatory) -> identifies the entity
  * SpecType (mandatory) -> 'signal'
  * Value (optional) -> the primary value for this entity (not necessarily the display value)
  * Signal (mandatory) -> name of the signal
  * SignalValue (optional) -> the display value of the signal
  * Force (optional) -> container selector.
  * Keys (optional) -> Collection of key/value pairs to be inserted into exchange.
#>

class SignalEntity : BoundEntity {
  [string]$_signalOverrideValue;

  SignalEntity([PSCustomObject]$spec): base($spec) {
    $this.Spec = $spec;
  }

  [void] Exec ([BootStrap]$bootstrap) {
    ([BoundEntity]$this).Exec($bootstrap);
  
    if (-not([string]::IsNullOrEmpty($(Get-PsObjectField -Object $this.Spec -Field 'Signal')))) {
      # Invoke Select-SignalContainer
      #
      [hashtable]$parameters = @{
        'Containers' = $bootstrap.Containers;
        'Signals'    = $bootstrap.Signals;
        'Name'       = $this.Spec.Signal;
        'Threshold'  = $bootstrap.Threshold;
      }

      if (-not([string]::IsNullOrEmpty($this._signalOverrideValue))) {
        $parameters['Value'] = $this._signalOverrideValue;
      }
      elseif (-not([string]::IsNullOrEmpty($this.Spec.SignalValue))) {
        $parameters['Value'] = $this.Spec.SignalValue;
      }

      if (-not([string]::IsNullOrEmpty($this.Spec.CustomLabel))) {
        $parameters['CustomLabel'] = $this.Spec.CustomLabel;
      }

      if (-not([string]::IsNullOrEmpty($this.Spec.Format))) {
        $parameters['Format'] = $this.Spec.Format;
      }

      if (-not([string]::IsNullOrEmpty($this.Spec.Force))) {
        $parameters['Force'] = $this.Spec.Force;
      }

      Select-SignalContainer @parameters;
    }
  }
}

<#
  .NAME
    RegexEntity
 
  .SYNOPSIS
    For regular expressions.
 
  .DESCRIPTION
    Used to create a regex entity. The entity can represent either a parameter or an
  independent regex.
    A derived regex entity can be created which references another regex. The derived
  value must reference the dependency by including the static place holder string
  '*{_dependency}'.
 
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Name (mandatory) -> identifies the entity
  * Value (optional) -> the value of the user supplied expression (including occurrence)
  * Signal (optional) -> should be provided for parameters, optional for non parameters
  * WholeSpecifier (optional) -> single letter code identifying this regex parameter.
  * Force (optional) -> container selector.
  * RegExKey (optional) -> Key identifying where the internally created [regex] object
    is stored in exchange.
  * OccurrenceKey (optional) -> Key identifying where the occurrence value for this regex
    is stored in exchange.
  * Keys (optional) -> Collection of key/value pairs to be inserted into exchange.
 
  For derived:
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Dependency (mandatory) -> Name of required regex entity
  * Name (mandatory)
  * SpecType (mandatory) -> 'regex'
  * Value -> The pattern which should include placeholder '*{_dependency}'
  * RegExKey (optional)
  * OccurrenceKey (optional)
#>

class RegexEntity : SignalEntity {
  [regex]$RegEx;
  [string]$Occurrence;

  RegexEntity([PSCustomObject]$spec): base($spec) {

  }

  [void] Exec ([BootStrap]$bootstrap) {
    if (-not([string]::IsNullOrEmpty($(Get-PsObjectField -Object $this.Spec -Field 'Dependency')))) {
      if ([string]::IsNullOrEmpty($this.Spec.Value)) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          "RegexEntity.Exec '$($this.Spec.Name)', Value is undefined");
      }

      [BoundEntity]$bound = $bootstrap.Get($this.Spec.Dependency);

      if ($bound -is [RegexEntity]) {
        [RegexEntity]$dependency = [RegexEntity]$bound;

        if (-not($dependency.Executed)) {
          $dependency.Exec($bootstrap);
        }

        $this.Spec.Value = $this.Spec.Value.Replace(
          '*{_dependency}', $dependency.RegEx.ToString());
      }
      else {
        throw [System.Management.Automation.MethodInvocationException]::new(
          "RegexEntity.Exec '$($this.Spec.Name)', Dependency: '$($bound._param.Name)' is not a RegEx");
      }
    }

    # Create the expression & occurrence
    #
    [string]$expression, $this.Occurrence = Resolve-PatternOccurrence $this.Spec.Value;
    $this._signalOverrideValue = $expression;

    ([SignalEntity]$this).Exec($bootstrap);

    # Create the regex
    #
    [string]$specifier = Get-PsObjectField -Object $this.Spec -Field 'WholeSpecifier';
    [boolean]$whole = [string]::IsNullOrEmpty($specifier) ? $false : `
    $($bootstrap.Options.Whole -and -not([string]::IsNullOrEmpty($bootstrap.Options.Whole)) `
        -and ($bootstrap.Options.Whole -in @('*', $specifier)));

    $this.RegEx = New-RegularExpression -Expression $expression -WholeWord:$whole;

    # Populate Keys
    #
    if ($(Get-PsObjectField -Object $this.Spec -Field 'RegExKey')) {
      if ($bootstrap.Exchange.ContainsKey($this.Spec.RegExKey)) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          "RegexEntity.Exec ['$($this.Spec.Name)'] RegEx Key: '$($this.Spec.RegExKey)' already exists");
      }
      $bootstrap.Exchange[$this.Spec.RegExKey] = $this.RegEx;
    }

    if ($(Get-PsObjectField -Object $this.Spec -Field 'OccurrenceKey')) {
      if ($bootstrap.Exchange.ContainsKey($this.Spec.OccurrenceKey)) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          "RegexEntity.Exec ['$($this.Spec.Name)'] Occurrence Key: '$($this.Spec.OccurrenceKey)' already exists");
      }
      $bootstrap.Exchange[$this.Spec.OccurrenceKey] = $this.Occurrence;
    }
  }
}

<#
  .NAME
    FormatterEntity
 
  .SYNOPSIS
    For formatter parameters
 
  .DESCRIPTION
    This is a signal entity with the addition of a validator which checks that the
  value represented does not contain file system unsafe characters. Uses function
  Test-IsFileSystemSafe to perform this check.
 
  * Activate (primary: mandatory) -> flag to indicate if the entity is to be created.
  * Activator (relation: mandatory) -> predicate to indicate if the entity is to be created.
  * Name (mandatory) -> identifies the entity
  * SpecType (mandatory) -> 'formatter'
  * Value (optional) -> the value of the user supplied expression (including occurrence)
  * Signal (optional) -> should be provided for parameters, optional for non parameters
  * WholeSpecifier (optional) -> single letter code identifying this regex parameter.
  * Force (optional) -> container selector.
  * Keys (optional) -> Collection of key/value pairs to be inserted into exchange.
#>

class FormatterEntity : SignalEntity {
  FormatterEntity([PSCustomObject]$spec): base($spec) {

  }

  [void] Exec ([BootStrap]$bootstrap) {
    ([SignalEntity]$this).Exec($bootstrap);

    if (-not(Test-IsFileSystemSafe -Value $this.Spec.Value)) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        "'$($this.Spec.Name)' parameter ('$($this.Spec.Value)') contains unsafe characters");
    }
  }
}

class BootStrap {

  [hashtable]$Exchange;
  [PSCustomObject]$Containers;
  [hashtable]$Signals;
  [int]$Threshold = 6;
  [PSCustomObject]$Options;
  [hashtable]$_entities;
  [hashtable]$_relations;
  [boolean]$_built = $false;

  BootStrap([hashtable]$exchange, [PSCustomObject]$containers,
    [PSCustomObject]$options) {

    $this.Exchange = $exchange;
    $this.Containers = $containers;
    $this.Signals = $exchange['LOOPZ.SIGNALS'];
    $this.Options = $options;
    $this._entities = [ordered]@{};
    $this._relations = [ordered]@{};
  }

  [BoundEntity] Create([PSCustomObject]$spec) {
    [BoundEntity]$instance = switch ($spec.SpecType) {
      'formatter' {
        [FormatterEntity]::new($spec);
        break;
      }

      'regex' {
        [RegexEntity]::new($spec);
        break;
      }

      'signal' {
        [SignalEntity]::new($spec);
        break;
      }

      'simple' {
        [SimpleEntity]::new($spec);
        break;
      }

      default {
        throw [System.Management.Automation.MethodInvocationException]::new(
          "BootStrap.Create: Invalid SpecType: '$($spec.SpecType)'.");        
      }
    }

    return $instance;
  }

  [void] Register([PSCustomObject]$spec) {
    $this._bind($this.Create($spec));
  }

  [hashtable] Build([array]$relations) {

    if (-not($this._built)) {
      if ($this._entities.PSBase.Count -gt 0) {
        $this._entities.GetEnumerator() | ForEach-Object {
          if (-not($_.Value.Executed)) {
            $_.Value.Exec($this);
          }
        }
      }

      if ($relations.Count -gt 0) {
        foreach ($relatedSpec in $relations) {
          if ($relatedSpec -is [PSCustomObject]) {
            [scriptblock]$activator = Get-PsObjectField -Object $relatedSpec -Field 'Activator';

            if ($activator) {

              if ($this._entities.ContainsKey($relatedSpec.Name)) {
                throw [System.Management.Automation.MethodInvocationException]::new(
                  "BootStrap.Build: Relation: '$($relatedSpec.Name)' already exists as primary entity.");
              }

              if ($this._relations.ContainsKey($relatedSpec.Name)) {
                throw [System.Management.Automation.MethodInvocationException]::new(
                  "BootStrap.Build: Relation: '$($relatedSpec.Name)' already exists as relation.");
              }

              # Assumption: client makes no changes are made to the _relations collection
              # as it is currently being iterated. Any structural modifications to the
              # collection are likely to result in unexpected results and/or errors.
              #
              if ($activator.InvokeReturnAsIs($this._entities, $this._relations)) {
                [BoundEntity]$relatedEntity = $this.Create($relatedSpec);
                $this._relations[$relatedEntity.Spec.Name] = $relatedEntity;

                $relatedEntity.Exec($this);
              }
            }
            else {
              throw [System.Management.Automation.MethodInvocationException]::new(
                "BootStrap.Build: Relation: '$($relatedSpec.Name)' does not contain valid Activator.");
            }
          }
        }
      }

      if ($this.Containers.Wide.Line.Length -gt 0) {
        $this.Exchange['LOOPZ.SUMMARY-BLOCK.WIDE-ITEMS'] = $this.Containers.Wide;
      }

      if ($this.Containers.Props.Line.Length -gt 0) {
        $this.Exchange['LOOPZ.SUMMARY.PROPERTIES'] = $this.Containers.Props;
      }

      $this._built = $true;
    }

    return $this.Exchange;
  }

  [PSCustomObject] Get ([string]$name) {
    [PSCustomObject]$result = if ($this._entities.ContainsKey($name)) {
      $this._entities[$name];
    }
    elseif ($this._relations.ContainsKey($name)) {
      $this._relations[$name];
    }
    else {
      $null;
    }

    return $result;
  }

  [boolean] Contains ([string]$name) {
    return ($null -ne $this.Get($name));
  }

  [void] _bind([BoundEntity]$entity) {
    if ($entity.Spec.Activate) {
      if ($this._entities.ContainsKey($entity.Spec.Name)) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          "BootStrap._bind: Item with id: '$($entity.Spec.Name)' already exists.");
      }

      $this._entities[$entity.Spec.Name] = $entity;
    }
  }
}

# The only reason why all the controller classes are implemented in the same file is because
# there is a deficiency in PSScriptAnalyzer (in VSCode) which reports class references as errors
# if they are not defined in the same file from where they are referenced. The only way to circumvent
# this problem is to place all class related code into the same file.
#
enum ControllerType {
  ForeachCtrl = 0
  TraverseCtrl = 1
}

class Counter {
  [int]hidden $_errors = 0;
  [int]hidden $_value = 0;
  [int]hidden $_skipped = 0;
  [int]hidden $_triggerCount = 0;

  [int] Increment() {
    return ++$this._value;
  }

  [int] Value() {
    return $this._value;
  }

  [int] IncrementError() {
    return ++$this._errors;
  }

  [int] Errors() {
    return $this._errors;
  }

  [int] IncrementSkipped() {
    return ++$this._skipped;
  }

  [int] Skipped() {
    return $this._skipped;
  }

  [int] IncrementTrigger() {
    return ++$this._triggerCount;
  }

  [int] TriggerCount() {
    return $this._triggerCount;
  }
}

class BaseController {
  [scriptblock]$_header;
  [scriptblock]$_summary;
  [hashtable]hidden $_exchange;
  [int]hidden $_index = 0;
  [boolean]$_trigger = $false;
  [boolean]hidden $_broken = $false;
  [object]$_scribbler;

  BaseController([hashtable]$exchange,
    [scriptblock]$header,
    [scriptblock]$summary) {
    $this._exchange = $exchange;
    $this._header = $header;
    $this._summary = $summary;

    $this._scribbler = $Exchange['LOOPZ.SCRIBBLER'];
    if (-not($this._scribbler)) {
      [object]$krayon = $(Get-Krayon);

      $this._scribbler = New-Scribbler -Krayon $krayon -Silent;
      $Exchange['LOOPZ.SCRIBBLER'] = $this._scribbler;
    }
  }

  [int] RequestIndex() {
    if ($this._exchange.ContainsKey('LOOPZ.CONTROLLER.STACK')) {
      $this._exchange['LOOPZ.CONTROLLER.STACK'].Peek().Increment();
    }
    return $this._exchange['LOOPZ.FOREACH.INDEX'] = $this._index++;
  }

  [boolean] IsBroken () {
    return $this._broken;
  }

  [boolean] GetTrigger() {
    return $this._trigger;
  }

  # I don't know how to define abstract methods in PowerShell classes, so
  # throwing an exception is the best thing we can do for now.
  #
  [void] SkipItem() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (BaseController.SkipItem)');
  }

  [int] Skipped() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (BaseController.Skipped)');
  }

  [void] ErrorItem() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (BaseController.ErrorItem)');
  }

  [int] Errors() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (BaseController.Errors)');
  }

  [void] TriggerItem() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (BaseController.TriggerItem)');
  }

  [int] TriggerCount() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (BaseController.TriggerCount)');
  }

  [void] ForeachBegin () {
    [System.Collections.Stack]$stack = $this._exchange.ContainsKey('LOOPZ.CONTROLLER.STACK') `
      ? ($this._exchange['LOOPZ.CONTROLLER.STACK']) : ([System.Collections.Stack]::new());

    if (-not($this._exchange.ContainsKey('LOOPZ.CONTROLLER.STACK'))) {
      $this._exchange['LOOPZ.CONTROLLER.STACK'] = $stack;
    }

    $stack.Push([Counter]::new());
    $this._exchange['LOOPZ.CONTROLLER.DEPTH'] = $stack.Count;
    $this._header.InvokeReturnAsIs($this._exchange);
  }

  [void] ForeachEnd () {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (BaseController.ForeachEnd)');
  }

  [void] BeginSession () {}
  [void] EndSession () {}

  [void] HandleResult([PSCustomObject]$invokeResult) {
    # Note, the _index at this point has already been incremented and refers to the
    # next allocated item.
    #
    if ($invokeResult) {
      if ($invokeResult.psobject.properties.match('Trigger') -and $invokeResult.Trigger) {
        $this.TriggerItem();
      }

      if ($invokeResult.psobject.properties.match('Break') -and $invokeResult.Break) {
        $this._broken = $true;
      }

      if ($invokeResult.psobject.properties.match('Skipped') -and $invokeResult.Skipped) {
        $this.SkipItem();
      }

      if ($invokeResult.psobject.properties.match('ErrorReason') -and
        ($invokeResult.ErrorReason -is [string])) {
        $this.ErrorItem();
      }
    }

    $this._scribbler.Flush();
  }
}

class ForeachController : BaseController {
  [int]hidden $_skipped = 0;
  [int]hidden $_errors = 0;
  [int]hidden $_triggerCount = 0;

  ForeachController([hashtable]$exchange,
    [scriptblock]$header,
    [scriptblock]$summary
  ): base($exchange, $header, $summary) {

  }

  [void] SkipItem() {
    $this._skipped++;
  }

  [int] Skipped() {
    return $this._skipped;
  }

  [void] ErrorItem() {
    $this._errors++;
  }

  [int] Errors() {
    return $this._errors;
  }

  [void] TriggerItem() {
    $this._exchange['LOOPZ.FOREACH.TRIGGER'] = $true;
    $this._trigger = $true;

    $this._triggerCount++;
  }

  [int] TriggerCount() {
    return $this._triggerCount;
  }

  [void] ForeachEnd () {
    $this._exchange['LOOPZ.FOREACH.TRIGGER'] = $this._trigger;
    $this._exchange['LOOPZ.FOREACH.COUNT'] = $this._index;
    $this._exchange['LOOPZ.FOREACH.TRIGGER-COUNT'] = $this._triggerCount;

    $this._summary.InvokeReturnAsIs($this._index, $this._skipped, $this._errors,
      $this._trigger, $this._exchange);

    $this._scribbler.Flush();
  }
}

class TraverseController : BaseController {

  [PSCustomObject]$_session = @{
    Count   = 0;
    Errors  = 0;
    Skipped = 0;
    TriggerCount = 0;
    Trigger = $false;
    Header  = $null;
    Summary = $null;
  }

  TraverseController([hashtable]$exchange,
    [scriptblock]$header,
    [scriptblock]$summary,
    [scriptblock]$sessionHeader,
    [scriptblock]$sessionSummary
  ): base($exchange, $header, $summary) {
    $this._session.Header = $sessionHeader;
    $this._session.Summary = $sessionSummary;
  }

  [void] SkipItem() {
    $this._exchange['LOOPZ.CONTROLLER.STACK'].Peek().IncrementSkipped();
  }

  [int] Skipped() {
    return $this._session.Skipped;
  }

  [void] ErrorItem() {
    $this._exchange['LOOPZ.CONTROLLER.STACK'].Peek().IncrementError();
  }

  [int] Errors() {
    return $this._session.Errors;
  }

  [void] TriggerItem() {
    $this._exchange['LOOPZ.CONTROLLER.STACK'].Peek().IncrementTrigger();

    $this._exchange['LOOPZ.FOREACH.TRIGGER'] = $true;
    $this._trigger = $true;
  }

  [int] TriggerCount() {
    return $this._session.TriggerCount;
  }

  [void] ForeachEnd () {
    $this._exchange['LOOPZ.FOREACH.TRIGGER'] = $this._trigger;
    $this._exchange['LOOPZ.FOREACH.COUNT'] = $this._index;

    [System.Collections.Stack]$stack = $this._exchange['LOOPZ.CONTROLLER.STACK'];
    [Counter]$counter = $stack.Pop();
    $this._exchange['LOOPZ.CONTROLLER.DEPTH'] = $stack.Count;
    $this._exchange['LOOPZ.FOREACH.TRIGGER-COUNT'] = $counter.TriggerCount();
    $this._session.Count += $counter.Value();
    $this._session.Errors += $counter.Errors();
    $this._session.Skipped += $counter.Skipped();
    $this._session.TriggerCount += $counter.TriggerCount();
    if ($this._trigger) {
      $this._session.Trigger = $true;
    }

    $this._summary.InvokeReturnAsIs($counter.Value(), $counter.Skipped(),
      $counter.Errors(), $this._trigger, $this._exchange);
  }

  [void] BeginSession () {
    [System.Collections.Stack]$stack = [System.Collections.Stack]::new();

    # The Counter for the session represents the top-level invoke
    #
    $stack.Push([Counter]::new());
    $this._exchange['LOOPZ.CONTROLLER.STACK'] = $stack;
    $this._exchange['LOOPZ.CONTROLLER.DEPTH'] = $stack.Count;
    $this._session.Header.InvokeReturnAsIs($this._exchange);
  }

  [void] EndSession () {
    [System.Collections.Stack]$stack = $this._exchange['LOOPZ.CONTROLLER.STACK'];

    # This counter value represents the top-level invoke which is not included in
    # a foreach sequence.
    #
    [Counter]$counter = $stack.Pop();
    $this._exchange['LOOPZ.CONTROLLER.DEPTH'] = $stack.Count;
    $this._exchange['LOOPZ.FOREACH.TRIGGER-COUNT'] = $this._session.TriggerCount;

    if ($stack.Count -eq 0) {
      $this._exchange.Remove('LOOPZ.CONTROLLER.STACK');
    }
    else {
      Write-Warning "!!!!!! END-SESSION; stack contains $($stack.Count) excess items";
    }

    $this._session.Count += $counter.Value();
    $this._session.Summary.InvokeReturnAsIs($this._session.Count,
      $this._session.Skipped,
      $this._session.Errors,
      $this._session.Trigger,
      $this._exchange
    );

    $this._scribbler.Flush();
  }
}

function New-Controller {
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions',
    '', Justification = 'Not a state changing function, its a factory')]
  [CmdletBinding(DefaultParameterSetName = 'Iterating')]
  [OutputType([BaseController])]
  param (
    [Parameter(ParameterSetName = 'Iterating')]
    [Parameter(ParameterSetName = 'Traversing')]
    [ControllerType]$Type,

    [Parameter(ParameterSetName = 'Iterating')]
    [Parameter(ParameterSetName = 'Traversing')]
    [hashtable]$Exchange,

    [Parameter(ParameterSetName = 'Iterating')]
    [Parameter(ParameterSetName = 'Traversing')]
    [scriptblock]$Header,

    [Parameter(ParameterSetName = 'Iterating')]
    [Parameter(ParameterSetName = 'Traversing')]
    [scriptblock]$Summary,

    [Parameter(ParameterSetName = 'Traversing')]
    [scriptblock]$SessionHeader,

    [Parameter(ParameterSetName = 'Traversing')]
    [scriptblock]$SessionSummary
  )

  $instance = $null;

  switch ($Type) {
    ForeachCtrl {
      $instance = [ForeachController]::new($Exchange, $Header, $Summary);
      break;
    }

    TraverseCtrl {
      $instance = [TraverseController]::new($Exchange, $Header, $Summary,
        $SessionHeader, $SessionSummary);
      break;
    }
  }

  $instance;
}

class EndAdapter {
  [System.IO.FileSystemInfo]$_fsInfo;
  [boolean]$_isDirectory;
  [string]$_adjustedName;

  EndAdapter([System.IO.FileSystemInfo]$fsInfo) {
    $this._fsInfo = $fsInfo;
    $this._isDirectory = ($fsInfo.Attributes -band
      [System.IO.FileAttributes]::Directory) -eq [System.IO.FileAttributes]::Directory;

    $this._adjustedName = $this._isDirectory ? $fsInfo.Name `
      : [System.IO.Path]::GetFileNameWithoutExtension($this._fsInfo.Name);
  }

  [string] GetAdjustedName() {
    return $this._adjustedName;
  }

  [string] GetNameWithExtension([string]$newName) {
    [string]$result = ($this._isDirectory) ? $newName `
      : ($newName + [System.IO.Path]::GetExtension($this._fsInfo.Name));

    return $result;
  }

  [string] GetNameWithExtension([string]$newName, [string]$extension) {
    [string]$result = ($this._isDirectory) ? $newName `
      : ($newName + $extension);

    return $result;
  }
}

function New-EndAdapter {
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions',
    '', Justification = 'Not a state changing function, its a factory')]
  param(
    [System.IO.FileSystemInfo]$fsInfo
  )
  return [EndAdapter]::new($fsInfo);
}

class ParameterSetRule {
  [string]$RuleName;
  [string]$Short;
  [string]$Description;

  ParameterSetRule([string]$name) {
    $this.RuleName = $name;
  }

  [PSCustomObject] Query([PSCustomObject]$queryInfo) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (ParameterSetRule.Violations)');
  }

  [void] ViolationStmt([PSCustomObject[]]$pods, [PSCustomObject]$queryInfo) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (ParameterSetRule.ViolationStmt)');
  }
} # ParameterSetRule

class MustContainUniqueSetOfParams : ParameterSetRule {

  MustContainUniqueSetOfParams([string]$name):base($name) {
    $this.Short = 'Non Unique Parameter Set';
    $this.Description =
    "Each parameter set must have at least one unique parameter. " +
    "If possible, make this parameter a mandatory parameter.";
  }

  [PSCustomObject] Query([PSCustomObject]$queryInfo) {
    [object]$syntax = $queryInfo.Syntax;
    [PSCustomObject[]]$pods = find-DuplicateParamSets -CommandInfo $queryInfo.CommandInfo `
      -Syntax $syntax;

    [string]$paramSetNameSnippet = $syntax.TableOptions.Snippets.ParamSetName;
    [string]$resetSnippet = $syntax.TableOptions.Snippets.Reset;

    [PSCustomObject]$vo = if ($pods -and $pods.Count -gt 0) {

      [string[]]$reasons = $pods | ForEach-Object {
        $(
          "{$($paramSetNameSnippet)$($_.First.Name)$($resetSnippet)/" +
          "$($paramSetNameSnippet)$($_.Second.Name)$($resetSnippet)}"
        );
      }
      [PSCustomObject]@{
        Rule       = $this.RuleName;
        Violations = $pods;
        Reasons    = $reasons;
      }
    }
    else {
      $null;
    }

    return $vo;
  }

  [void] ViolationStmt([PSCustomObject[]]$pods, [PSCustomObject]$queryInfo) {

    [object]$syntax = $queryInfo.Syntax;
    [object]$scribbler = $queryInfo.Scribbler;
    [PSCustomObject]$options = $syntax.TableOptions;
    [string]$lnSnippet = $options.Snippets.Ln;
    [string]$resetSnippet = $options.Snippets.Reset;
    [string]$duplicateSeparator = '.............';
    [string]$underlineSnippet = $options.Snippets.HeaderUL;
    [string]$doubleIndentation = $syntax.Indent(2);

    if ($pods -and ($pods.Count -gt 0)) {
      $scribbler.Scribble(
        "$($doubleIndentation)$($underlineSnippet)$($duplicateSeparator)$($lnSnippet)"
      );

      foreach ($seed in $pods) {
        [string]$duplicateParamSetStmt = $syntax.DuplicateParamSetStmt(
          $seed.First, $seed.Second
        );
        $scribbler.Scribble($duplicateParamSetStmt);

        [string]$firstParamSetStmt = $syntax.ParamSetStmt($queryInfo.CommandInfo, $seed.First);
        [string]$secondParamSetStmt = $syntax.ParamSetStmt($queryInfo.CommandInfo, $seed.Second);

        [string]$firstSyntax = $syntax.SyntaxStmt($seed.First);
        [string]$secondSyntax = $syntax.SyntaxStmt($seed.Second);

        $scribbler.Scribble($(
            "$($lnSnippet)" +
            "$($firstParamSetStmt)$($lnSnippet)$($firstSyntax)$($lnSnippet)" +
            "$($lnSnippet)" +
            "$($secondParamSetStmt)$($lnSnippet)$($secondSyntax)$($lnSnippet)" +
            "$($doubleIndentation)$($underlineSnippet)$($duplicateSeparator)$($lnSnippet)"
          ));

        [string]$subTitle = $syntax.QuotedNameStmt(
          $syntax.TableOptions.Snippets.ParamSetName,
          $seed.First.Name, '('
        );

        $queryInfo.CommandInfo | Show-ParameterSetInfo `
          -Sets @($seed.First.Name) -Scribbler $scribbler `
          -Title $(
          "FIRST $($subTitle)$($resetSnippet) Parameter Set Report"
        );
      }
    }
  }
} # MustContainUniqueSetOfParams

class MustContainUniquePositions : ParameterSetRule {
  MustContainUniquePositions([string]$name):base($name) {
    $this.Short = 'Non Unique Positions';
    $this.Description =
    "A parameter set that contains multiple positional parameters must " +
    "define unique positions for each parameter. No two positional parameters " +
    "can specify the same position.";
  }

  [PSCustomObject] Query([PSCustomObject]$queryInfo) {
    [object]$syntax = $queryInfo.Syntax;
    [PSCustomObject[]]$pods = find-DuplicateParamPositions -CommandInfo $queryInfo.CommandInfo `
      -Syntax $syntax;

    [string]$paramSetNameSnippet = $syntax.TableOptions.Snippets.ParamSetName;
    [string]$resetSnippet = $syntax.TableOptions.Snippets.Reset;

    [PSCustomObject]$vo = if ($pods -and $pods.Count -gt 0) {

      [string[]]$reasons = $pods | ForEach-Object {
        [string]$resolvedParamStmt = $syntax.ResolvedParamStmt($_.Params, $_.ParamSet);
        $(
          "{$($paramSetNameSnippet)$($_.ParamSet.Name)$($resetSnippet)" +
          " => $resolvedParamStmt$($resetSnippet)}"
        );
      }
      [PSCustomObject]@{
        Rule       = $this.RuleName;
        Violations = $pods;
        Reasons    = $reasons;
      }
    }
    else {
      $null;
    }

    return $vo;
  }

  [void] ViolationStmt([PSCustomObject[]]$pods, [PSCustomObject]$queryInfo) {
    [object]$syntax = $queryInfo.Syntax;
    [object]$scribbler = $queryInfo.Scribbler;
    [PSCustomObject]$options = $syntax.TableOptions;
    [string]$lnSnippet = $options.Snippets.Ln;
    if ($pods -and ($pods.Count -gt 0)) {
      foreach ($seed in $pods) {
        [string]$duplicateParamPositionsStmt = $( # BULLET-C/SEED
          "$($syntax.ParamsDuplicatePosStmt($seed))" +
          "$($lnSnippet)$($lnSnippet)"
        );

        $scribbler.Scribble($duplicateParamPositionsStmt);
      }
    }
  }
} # MustContainUniquePositions

class MustNotHaveMultiplePipelineParams : ParameterSetRule {
  MustNotHaveMultiplePipelineParams([string]$name):base($name) {
    $this.Short = 'Multiple Claims to Pipeline item';
    $this.Description =
    "Only one parameter in a set can declare the ValueFromPipeline " +
    "keyword with a value of true.";
  }

  [PSCustomObject] Query([PSCustomObject]$queryInfo) {
    [object]$syntax = $queryInfo.Syntax;
    [PSCustomObject[]]$pods = find-MultipleValueFromPipeline -CommandInfo $queryInfo.CommandInfo `
      -Syntax $syntax;

    [string]$paramSetNameSnippet = $syntax.TableOptions.Snippets.ParamSetName;
    [string]$resetSnippet = $syntax.TableOptions.Snippets.Reset;

    [PSCustomObject]$vo = if ($pods -and $pods.Count -gt 0) {

      [string[]]$reasons = $pods | ForEach-Object {
        [string]$resolvedParamStmt = $syntax.ResolvedParamStmt($_.Params, $_.ParamSet);
        $(
          "{$($paramSetNameSnippet)$($_.ParamSet.Name)$($resetSnippet)" +
          " => $resolvedParamStmt$($resetSnippet)}"
        );
      }
      [PSCustomObject]@{
        Rule       = $this.RuleName;
        Violations = $pods;
        Reasons    = $reasons;
      }
    }
    else {
      $null;
    }

    return $vo;
  }

  [void] ViolationStmt([PSCustomObject[]]$pods, [PSCustomObject]$queryInfo) {
    [object]$syntax = $queryInfo.Syntax;
    [object]$scribbler = $queryInfo.Scribbler;
    [PSCustomObject]$options = $syntax.TableOptions;
    [string]$lnSnippet = $options.Snippets.Ln;
    if ($pods -and ($pods.Count -gt 0)) {
      foreach ($seed in $pods) {
        [string]$multipleClaimsStmt = $(
          "$($syntax.MultiplePipelineItemClaimStmt($seed))" +
          "$($lnSnippet)$($lnSnippet)"
        );

        $scribbler.Scribble($multipleClaimsStmt);
      }
    }
  }
} # MustNotHaveMultiplePipelineParams

class MustNotBeInAllParameterSetsByAccident : ParameterSetRule {
  MustNotBeInAllParameterSetsByAccident([string]$name):base($name) {
    $this.Short = 'In All Parameter Sets By Accident';
    $this.Description =
    "Defining a parameter with multiple 'Parameter Blocks', some with " +
    "and some without a parameter set, is invalid.";
  }

  [PSCustomObject] Query([PSCustomObject]$queryInfo) {
    [object]$syntax = $queryInfo.Syntax;
    [PSCustomObject[]]$pods = find-InAllParameterSetsByAccident -CommandInfo $queryInfo.CommandInfo `
      -Syntax $syntax;

    [string]$paramSetNameSnippet = $syntax.TableOptions.Snippets.ParamSetName;
    [string]$resetSnippet = $syntax.TableOptions.Snippets.Reset;

    [PSCustomObject]$vo = if ($pods -and $pods.Count -gt 0) {

      [string[]]$reasons = $pods | ForEach-Object {
        [string]$resolvedParam = $syntax.ResolvedParamStmt(@($_.Param), $_.ParamSet);
        $(
          "{ parameter $($resetSnippet)$($resolvedParam)$($resetSnippet) of " +
          "parameter set $($paramSetNameSnippet)$($_.ParamSet.Name)"
        );
      }
      [PSCustomObject]@{
        Rule       = $this.RuleName;
        Violations = $pods;
        Reasons    = $reasons;
      }
    }
    else {
      $null;
    }

    return $vo;
  }

  [void] ViolationStmt([PSCustomObject[]]$pods, [PSCustomObject]$queryInfo) {
    [object]$syntax = $queryInfo.Syntax;
    [object]$scribbler = $queryInfo.Scribbler;
    [PSCustomObject]$options = $syntax.TableOptions;
    [string]$lnSnippet = $options.Snippets.Ln;
    if ($pods -and ($pods.Count -gt 0)) {
      foreach ($seed in $pods) {
        [string]$accidentsStmt = $( # BULLET-C/SEED
          "$($syntax.InAllParameterSetsByAccidentStmt($seed))" +
          "$($lnSnippet)$($lnSnippet)"
        );

        $scribbler.Scribble($accidentsStmt);
      }
    }
  }
}

class RuleController {
  [string]$CommandName;
  [System.Management.Automation.CommandInfo]$CommandInfo;
  static [hashtable]$Rules = @{
    'UNIQUE-PARAM-SET'      = [MustContainUniqueSetOfParams]::new('UNIQUE-PARAM-SET');
    'UNIQUE-POSITIONS'      = [MustContainUniquePositions]::new('UNIQUE-POSITIONS');
    'SINGLE-PIPELINE-PARAM' = [MustNotHaveMultiplePipelineParams]::new('SINGLE-PIPELINE-PARAM');
    'ACCIDENTAL-ALL-SETS'   = [MustNotBeInAllParameterSetsByAccident]::new('ACCIDENTAL-ALL-SETS');
  }

  RuleController([System.Management.Automation.CommandInfo]$commandInfo) {
    $this.CommandName = $commandInfo.Name;
    $this.CommandInfo = $commandInfo;
  }

  [void] ViolationSummaryStmt([hashtable]$violationsByRule, [PSCustomObject]$queryInfo) {
    [object]$syntax = $queryInfo.Syntax;
    [object]$scribbler = $queryInfo.Scribbler;
    [PSCustomObject]$options = $syntax.TableOptions;
    [string]$resetSnippet = $options.Snippets.Reset;
    [string]$lnSnippet = $options.Snippets.Ln;
    [string]$ruleSnippet = $options.Snippets.HeaderUL;
    [string]$indentation = $syntax.Indent(1);
    [string]$doubleIndentation = $syntax.Indent(2);
    [string]$tripleIndentation = $syntax.Indent(3);

    [string]$summaryStmt = if ($violationsByRule.PSBase.Count -eq 0) {
      "$($options.Snippets.Ok) No violations found.$($lnSnippet)";
    }
    else {
      [int]$total = 0;
      [System.Text.StringBuilder]$buildR = [System.Text.StringBuilder]::new();

      $violationsByRule.GetEnumerator() | ForEach-Object {
        [PSCustomObject]$vo = $_.Value;
        [PSCustomObject[]]$pods = $vo.Violations;
        $total += $pods.Count;

        [string]$shortName = [RuleController]::Rules[$_.Key].Short;
        [string]$quotedShortName = $syntax.QuotedNameStmt($ruleSnippet, $shortName);
        $null = $buildR.Append($(
            "$($indentation)$($signals['BULLET-A'].Value) " +
            "$($quotedShortName)$($resetSnippet), Count: $($pods.Count)$($lnSnippet)"
          ));

        $null = $buildR.Append($(
            "$($doubleIndentation)$($signals['BULLET-C'].Value)$($resetSnippet) Reasons: $($lnSnippet)"
          ));

        $vo.Reasons | ForEach-Object {
          $null = $buildR.Append($(
              "$($tripleIndentation)$($signals['BULLET-D'].Value) $($resetSnippet)$($_)$($lnSnippet)"
            ));
        }
      }

      [string]$plural = ($total -eq 1) ? 'violation' : 'violations';
      [string]$violationsByRuleStmt = $(
        "$($options.Snippets.Error) Found the following $($total) $($plural):$($lnSnippet)" +
        "$($resetSnippet)$($buildR.ToString())"
      );

      $violationsByRuleStmt;
    }

    $scribbler.Scribble(
      "$($resetSnippet)" +
      "$($lnSnippet)$($global:LoopzUI.EqualsLine)" +
      "$($lnSnippet)>>>>> SUMMARY: $($summaryStmt)$($resetSnippet)" +
      "$($global:LoopzUI.EqualsLine)" +
      "$($lnSnippet)"
    );
  }

  [void] ReportAll([PSCustomObject]$queryInfo) {
    [hashtable]$violationsByRule = @{};
    [object]$syntax = $queryInfo.Syntax;
    [object]$scribbler = $queryInfo.Scribbler;
    [PSCustomObject]$options = $syntax.TableOptions;
    [string]$lnSnippet = $options.Snippets.Ln;
    [string]$resetSnippet = $options.Snippets.Reset;
    [string]$headerSnippet = $options.Snippets.Heading;
    [string]$headerULSnippet = $options.Snippets.HeaderUL;

    [RuleController]::Rules.PSBase.Keys | Sort-Object | ForEach-Object {
      [string]$ruleNameKey = $_;
      [ParameterSetRule]$rule = [RuleController]::Rules[$ruleNameKey];

      [PSCustomObject]$vo = $rule.Query($queryInfo);
      [PSCustomObject[]]$pods = $vo.Violations;
      if ($pods -and ($pods.Count -gt 0)) {
        [string]$description = $queryInfo.Syntax.Fold(
          $rule.Description, $headerULSnippet, 80, $options.Chrome.Indent * 2
        );

        # Show the rule violation title
        #
        [string]$indentation = [string]::new(' ', $options.Chrome.Indent * 2);
        [string]$underline = [string]::new($options.Chrome.Underline, $($rule.Short.Length));
        [string]$ruleTitle = $(
          "$($lnSnippet)" +
          "$($indentation)$($headerSnippet)$($rule.Short)$($resetSnippet)" +
          "$($lnSnippet)" +
          "$($indentation)$($resetSnippet)$($headerULSnippet)$($underline)$($resetSnippet)" +
          "$($lnSnippet)$($lnSnippet)" +
          "$($resetSnippet)$($description)$($resetSnippet)" +
          "$($lnSnippet)$($lnSnippet)"
        );
        $scribbler.Scribble($ruleTitle);

        # Show the violations for this rule
        #
        $violationsByRule[$ruleNameKey] = $vo;
        $rule.ViolationStmt($pods, $queryInfo);
      }
    }

    $this.ViolationSummaryStmt($violationsByRule, $queryInfo);
  }

  [PSCustomObject[]] VerifyAll ([PSCustomObject]$queryInfo) {
    [PSCustomObject[]]$pods = $([RuleController]::Rules.GetEnumerator() | ForEach-Object {
      [ParameterSetRule]$rule = $_.Value;

      [PSCustomObject]$queryResult = $rule.Query($queryInfo);

      if ($queryResult) {
        $queryResult;
      }
    })

    return $pods;
  }

  [PSCustomObject] Test([object]$syntax) {
    [PSCustomObject]$queryInfo = [PSCustomObject]@{
      CommandInfo = $this.CommandInfo;
      Syntax      = $syntax;
    }

    [PSCustomObject[]]$pods = $this.VerifyAll($queryInfo);
    [PSCustomObject]$verifyResult = [PSCustomObject]@{
      Result = (-not($pods) -or ($pods.Count -eq 0))
      Violations = $pods;
    }
    return $verifyResult;
  }
} # RuleController

class DryRunner {
  [RuleController]$Controller;
  [System.Management.Automation.CommandInfo]$CommandInfo;
  [PSCustomObject]$RunnerInfo;

  DryRunner([RuleController]$controller, [PSCustomObject]$runnerInfo) {
    $this.Controller = $controller;
    $this.CommandInfo = $controller.CommandInfo;
    $this.RunnerInfo = $runnerInfo;
  }

  [System.Management.Automation.CommandParameterSetInfo[]] Weak([string[]]$params) {
    [System.Management.Automation.CommandParameterSetInfo[]]$candidateSets = `
      $this.CommandInfo.ParameterSets | Where-Object {
      $(
        (Test-ContainsAll $_.Parameters.Name $params)
      )
    };

    return $candidateSets;
  } # Weak

  [System.Management.Automation.CommandParameterSetInfo[]] Resolve([string[]]$params) {
    [System.Management.Automation.CommandParameterSetInfo[]]$candidateSets = `
      $this.CommandInfo.ParameterSets | Where-Object {
      $(
        (Test-ContainsAll $_.Parameters.Name $params) -and
        (Test-ContainsAll $params ($_.Parameters | Where-Object { $_.IsMandatory }).Name)
      )
    };

    return $candidateSets;
  } # Resolve
} # DryRunner

class Syntax {
  # PsSyntax
  [string]$CommandName;
  [hashtable]$Theme;
  [hashtable]$Signals
  [object]$Krayon;
  [object]$Scribbler;
  [hashtable]$Scheme;

  [string]$ParamNamePattern = "\-(?<name>\w+)";
  [string]$TypePattern = "\<(?<type>[\w\[\]]+)\>";
  [string]$NegativeTypePattern = "(?!\s+\<[\w\[\]]+\>)";

  [PSCustomObject]$Regex;
  [PSCustomObject]$Snippets;
  [PSCustomObject]$Formats;
  [PSCustomObject]$TableOptions;
  [PSCustomObject]$Labels;
  [regex]$NamesRegex;

  [string[]]$CommonParamSet = @('Verbose', 'Debug', 'ErrorAction', 'WarningAction',
    'InformationAction', 'VerboseAction', 'DebugAction', 'ProgressAction',
    'ErrorVariable', 'WarningVariable', 'InformationVariable', 'DebugVariable',
    'VerboseVariable', 'ProgressVariable', 'OutVariable', 'OutBuffer',
    'PipelineVariable');

  [string[]]$ShouldProcessParamSet = @('WhatIf', 'Confirm');
  [string[]]$AllCommonParamSet;

  static [hashtable]$CloseBracket = @{
    '(' = ')';
    '[' = ']';
    '{' = '}';
    '<' = '>';
  }

  static [string] $AllParameterSets = '__AllParameterSets';

  [scriptblock]$RenderCell = {
    [OutputType([boolean])]
    param(
      [string]$column,
      [string]$value,
      [PSCustomObject]$row,
      [PSCustomObject]$options,
      [object]$scribbler,
      [int]$counter
    )
    [boolean]$result = $true;

    # A warning about using -Regex option on a switch statement:
    # - Make sure that each switch branch has a break, this ensures that a single value
    # is handled only once.
    # - Since 'Name' is a substring of 'PipeName' the more prescriptive branch must appear first,
    # otherwise the wrong branch will be taken; If 'Name' case appears before 'PipeName' case, then
    # when $column is 'PipeName' could be handled by the 'Name' case which is not what we intended,
    # and is why the order of the cases matters.
    #
    # So, be careful using -Regex on switch statements.
    #
    switch -Regex ($column) {
      'Mandatory|PipeValue|PipeName|Unique' {
        [string]$padded = Format-BooleanCellValue -Value $value -TableOptions $options;
        $null = $scribbler.Scribble("$($options.Snippets.Reset)$($padded)");

        break;
      }

      'Name' {
        [string]$trimmed = $value.Trim();
        [System.Management.Automation.CommandParameterInfo]$parameterInfo = `
          $options.Custom.ParameterSetInfo.Parameters | Where-Object Name -eq $trimmed;
        [string]$parameterType = $parameterInfo.ParameterType;

        [string]$nameSnippet = if ($options.Custom.CommonParamSet.Contains($trimmed)) {
          $options.Custom.Snippets.Common;
        }
        elseif ($parameterInfo.IsMandatory) {
          $options.Custom.Snippets.Mandatory;
        }
        elseif ([string]$parameterType -eq 'switch') {
          $options.Custom.Snippets.Switch;
        }
        else {
          $options.Custom.Snippets.Cell;
        }
        $null = $scribbler.Scribble("$($nameSnippet)$($value)");

        break;
      }

      'Type' {
        $null = $scribbler.Scribble("$($options.Custom.Snippets.Type)$($value)");

        break;
      }

      default {
        # let's not do anything here and revert to default handling
        #
        $result = $false;
      }
    }
    # https://devblogs.microsoft.com/scripting/use-the-get-command-powershell-cmdlet-to-find-parameter-set-information/
    # https://blogs.msmvps.com/jcoehoorn/blog/2017/10/02/powershell-expandproperty-vs-property/

    return $result;
  } # RenderCell

  Syntax([string]$commandName, [hashtable]$signals, [object]$scribbler, [hashtable]$scheme) {
    $this.CommandName = $commandName;
    $this.Signals = $signals;
    $this.Scribbler = $scribbler;
    $this.Krayon = $scribbler.Krayon;
    $this.Theme = $this.Krayon.Theme;
    $this.Scheme = $scheme;

    $this.Regex = [PSCustomObject]@{
      Param = [PSCustomObject]@{
        OptionalPos_A   = New-RegularExpression `
          -Expression $("\[\[$($this.ParamNamePattern)\]\s$($this.TypePattern)\]");

        OptionalNamed_B = New-RegularExpression `
          -Expression $("\[$($this.ParamNamePattern)\s$($this.TypePattern)\]");

        ManNamed_C      = New-RegularExpression `
          -Expression $("$($this.ParamNamePattern)\s$($this.TypePattern)");

        ManPos_D        = New-RegularExpression `
          -Expression $("\[$($this.ParamNamePattern)\]\s$($this.TypePattern)");

        Switch          = New-RegularExpression `
          -Expression $("\[$($this.ParamNamePattern)\]$($this.NegativeTypePattern)");         
      }
    }

    $this.Snippets = [PSCustomObject]@{
      Punct        = $($this.Scribbler.Snippets($this.Scheme['COLS.PUNCTUATION']));
      Type         = $($this.Scribbler.Snippets($this.Scheme['COLS.TYPE']));
      Mandatory    = $($this.Scribbler.Snippets($this.Scheme['COLS.MAN-PARAM']));
      Optional     = $($this.Scribbler.Snippets($this.Scheme['COLS.OPT-PARAM']));
      Switch       = $($this.Scribbler.Snippets($this.Scheme['COLS.SWITCH']));
      Default      = $($this.Scribbler.Snippets($this.Scheme['COLS.CELL']));
      ParamSetName = $($this.Scribbler.Snippets($this.Scheme['COLS.PARAM-SET-NAME']));
      Command      = $($this.Scribbler.Snippets($this.Scheme['COLS.CMD-NAME']));
      HiLight      = $($this.Scribbler.Snippets($this.Scheme['COLS.HI-LIGHT']));

      HeaderUL     = $($this.Scribbler.Snippets($this.Scheme['COLS.HEADER-UL']));
      Special      = $($this.Scribbler.Snippets($this.Scheme['COLS.SPECIAL']));
      Error        = $($this.Scribbler.Snippets($this.Scheme['COLS.ERROR']));
      Ok           = $($this.Scribbler.Snippets($this.Scheme['COLS.OK']));

      Reset        = $($this.Scribbler.Snippets('Reset'));
      Space        = $($this.Scribbler.Snippets('Reset')) + ' ';
      Comma        = $($this.Scribbler.Snippets('Reset')) + ', ';
      Ln           = $($this.Scribbler.Snippets('Ln'));
      Heading      = $($this.Scribbler.Snippets(@('black', 'bgDarkYellow')));
    }

    $this.Formats = @{
      # NB: The single letter suffix attached to these names (A-D) are important as it reflects
      # the order in which regex replacement must occur. If these replacements are not done in
      # this strict order, then the correct replacements will not occur. This is because some
      # matches are sub-sets of others; eg
      # '-Param <type>' is a substring of '[-Param <type>]' so in the case, the latter replacement
      # must be performed before the former. The order of replacement goes from the most
      # prescriptive to the least.
      #
      OptionalPos_A    = [string]$(
        # [[-Param] <type>] ... optional, positional parameter
        #
        $this.Snippets.Punct + '[[' + $this.Snippets.Optional + '-${name}' + $this.Snippets.Punct + ']' +
        $this.Snippets.Space + '<' + $this.Snippets.Type + '${type}' + $this.Snippets.Punct + '>]'
      );

      OptionalNamed_B  = [string]$(
        # [-Param <type>] ... optional, non-positional parameter
        #
        $this.Snippets.Punct + '[' + $this.Snippets.Optional + '-${name}' + $this.Snippets.Type +
        $this.Snippets.Space +
        $this.Snippets.Punct + '<' + $this.Snippets.Type + '${type}' + $this.Snippets.Punct + '>]'
      );

      MandatoryNamed_C = [string]$(
        # -Param <type> ... mandatory, non-positional parameter
        # (requires passing with parameter name)
        #
        $this.Snippets.Mandatory + '-${name}' + $this.Snippets.Space + $this.Snippets.Punct +
        '<' + $this.Snippets.Type + '${type}' + $this.Snippets.Punct + '>'
      );

      MandatoryPos_D   = [string]$(
        # [-Param] <type> ... mandatory, positional parameter
        # (using the parameter name is optional, if passed in the right position among other
        # arguments passed positionally)
        #
        $this.Snippets.Punct + '[' + $this.Snippets.Mandatory + '-${name}' + $this.Snippets.Punct + ']' +
        $this.Snippets.Space +
        $this.Snippets.Punct + '<' + $this.Snippets.Type + '${type}' + $this.Snippets.Punct + '>'
      );

      # We need to use a negative look-ahead (?!) and only match if the param is not followed by <type>
      # [-Param]
      #
      OptionalSwitch   = [string]$(
        $this.Snippets.Punct + '[' + $this.Snippets.Switch + '-${name}' + $this.Snippets.Punct + ']'
      );

      # -Param
      # (Initially this might seem counter intuitive, since an Option/Flag is optional, but in the
      # context of a parameter set. An optional can be mandatory if the presence of the flag defines
      # that parameter set.)
      #
    }

    [PSCustomObject]$custom = [PSCustomObject]@{
      Colours          = [PSCustomObject]@{
        Mandatory = $this.Scheme['COLS.MAN-PARAM'];
        Switch    = $this.Scheme['COLS.SWITCH'];
        Title     = 'green';
      }

      Snippets         = [PSCustomObject]@{
        Header    = $($this.Scribbler.Snippets($this.Scheme['COLS.HEADER']));
        Underline = $($this.Scribbler.Snippets($this.Scheme['COLS.UNDERLINE']));
        Mandatory = $($this.Scribbler.Snippets($this.Scheme['COLS.MAN-PARAM']));
        Switch    = $($this.Scribbler.Snippets($this.Scheme['COLS.SWITCH']));
        Cell      = $($this.Scribbler.Snippets($this.Scheme['COLS.OPT-PARAM']));
        Type      = $($this.Scribbler.Snippets($this.Scheme['COLS.TYPE']));
        Command   = $($this.Scribbler.Snippets($this.Scheme['COLS.CMD-NAME']));
        Common    = $($this.Scribbler.Snippets($this.Scheme['COLS.COMMON']));
      }
      CommonParamSet   = $this.CommonParamSet;
      IncludeCommon    = $false;
      ParameterSetInfo = $null;
    }

    [string[]]$columns = @('Name', 'Type', 'Mandatory', 'Pos', 'PipeValue', 'PipeName', 'Alias', 'Unique');
    $this.TableOptions = Get-TableDisplayOptions -Select $columns  `
      -Signals $signals -Scribbler $this.Scribbler -Custom $custom;

    $this.TableOptions.Snippets = $this.Snippets;

    [string]$bulletedPoint = $(
      "$([string]::new(' ', $this.TableOptions.Chrome.Indent))" +
      "$($signals['BULLET-B'].Value)"
    );
    $this.Labels = [PSCustomObject]@{
      ParamSet                  = "====> Parameter Set: ";
      DuplicatePositions        = " *** Duplicate Positions for Parameter Set: ";
      MultipleValueFromPipeline = " *** Multiple ValueFromPipeline claims for Parameter Set: ";
      AccidentallyInAllSets     = " *** Parameter '{0}', accidentally in all Parameter Sets.";
      BulletedParams            = $(
        "$($bulletedPoint) Params: "
      );
      BulletedParam             = $(
        "$($bulletedPoint) Param: "
      );
      BulletedParamSet          = $(
        "$($bulletedPoint) Parameter Set: "
      );
    }

    $this.NamesRegex = New-RegularExpression -Expression '(?<name>\w+)';

    $this.AllCommonParamSet = $this.CommonParamSet + $this.ShouldProcessParamSet;
  } # ctor

  [string] TitleStmt([string]$title, [string]$commandName) {
    [string]$commandStmt = $this.QuotedNameStmt($this.Snippets.Command, $commandName, '[');
    [string]$titleStmt = $(
      "$($this.Snippets.Reset)$($this.Snippets.Ln)" +
      "----> $($title) $($commandStmt)$($this.Snippets.Reset) ..." +
      "$($this.Snippets.Ln)"
    );
    return $titleStmt;
  }

  [string] ParamSetStmt(
    [System.Management.Automation.CommandInfo]$commandInfo,
    [System.Management.Automation.CommandParameterSetInfo]$paramSet
  ) {

    [string]$defaultLabel = ($commandInfo.DefaultParameterSet -eq $paramSet.Name) `
      ? " (Default)" : [string]::Empty;

    [string]$structuredParamSetStmt = `
    $(
      "$($this.Snippets.Reset)$($this.Labels.BulletedParamSet)'" +
      "$($this.Snippets.ParamSetName)$($paramSet.Name)$($this.Snippets.Reset)'" +
      "$defaultLabel"
    );

    return $structuredParamSetStmt;
  }

  [string] ResolveParameterSnippet([System.Management.Automation.CommandParameterInfo]$paramInfo) {
    [string]$paramSnippet = if ($paramInfo.IsMandatory) {
      $this.TableOptions.Custom.Snippets.Mandatory;
    }
    elseif ([string]$paramInfo.ParameterType -eq 'switch') {
      $this.TableOptions.Custom.Snippets.Switch;
    }
    else {
      $this.TableOptions.Custom.Snippets.Cell;
    }
    return $paramSnippet;
  }

  [string] ResolvedParamStmt(
    [string[]]$params,
    [System.Management.Automation.CommandParameterSetInfo]$paramSet
  ) {
    [System.Text.StringBuilder]$buildR = [System.Text.StringBuilder]::new();
    [string]$commaSnippet = $this.Snippets.Comma;

    [int]$count = 0;
    foreach ($paramName in $params) {
      [System.Management.Automation.CommandParameterInfo[]]$paramResult = $(
        $paramSet.Parameters | Where-Object { $_.Name -eq $paramName }
      );

      if ($paramResult -and ($paramResult.Count -eq 1)) {
        [System.Management.Automation.CommandParameterInfo]$paramInfo = $paramResult[0];
        [string]$paramSnippet = $this.ResolveParameterSnippet($paramInfo)

        $null = $buildR.Append($(
            $this.QuotedNameStmt($paramSnippet, $paramName)
          ));

        if ($count -lt ($params.Count - 1)) {
          $null = $buildR.Append("$($commaSnippet)");
        }
      }
      $count++;
    }

    return $buildR.ToString();
  }

  [string] ParamsDuplicatePosStmt([PSCustomObject]$seed) {
    [string[]]$params = $seed.Params;
    [System.Management.Automation.CommandParameterSetInfo]$paramSet = $seed.ParamSet;
    [string]$positionNumber = $seed.Number;

    [string]$quotedPosition = $this.QuotedNameStmt($this.Snippets.Special, $positionNumber, '(');
    [string]$structuredStmt = $(
      "$($this.Snippets.Reset)$($this.Labels.DuplicatePositions)" +
      "$($this.QuotedNameStmt($($this.Snippets.ParamSetName), $paramSet.Name))" +
      "$($this.Snippets.Ln)" +
      "$($this.Snippets.Reset)$($this.Labels.BulletedParams) " +
      "$($quotedPosition) " +
      $this.ResolvedParamStmt($params, $paramSet)
    );

    return $structuredStmt;
  }

  [string] MultiplePipelineItemClaimStmt([PSCustomObject]$seed) {
    [string[]]$params = $seed.Params;
    [System.Management.Automation.CommandParameterSetInfo]$paramSet = $seed.ParamSet;

    [string]$structuredStmt = $(
      "$($this.Snippets.Reset)$($this.Labels.MultipleValueFromPipeline)" +
      "$($this.QuotedNameStmt($($this.Snippets.ParamSetName), $paramSet.Name))" +
      "$($this.Snippets.Ln)" +
      "$($this.Snippets.Reset)$($this.Labels.BulletedParams) " +
      $this.ResolvedParamStmt($params, $paramSet)
    );

    return $structuredStmt;
  }

  [string] InAllParameterSetsByAccidentStmt([PSCustomObject]$seed) {
    [string]$paramName = $seed.Param;
    [System.Management.Automation.CommandParameterSetInfo]$paramSet = $seed.ParamSet;
    [System.Management.Automation.CommandParameterInfo]$paramInfo = $($paramSet.Parameters | Where-Object {
        $_.Name -eq $paramName
      })[0];

    [string]$structuredParamName = $(
      "$($this.ResolveParameterSnippet($paramInfo))$($paramName)$($this.Snippets.Reset)"
    );

    # [string[]]$others = ($seed.Others | ForEach-Object {
    # $this.QuotedNameStmt($this.Snippets.ParamSetName, $_.Name)
    # }) -join "$($this.Snippets.Comma)";

    [string]$quotedParamSetName = $(
      "$($this.QuotedNameStmt($this.Snippets.ParamSetName, $paramSet.Name))" +
      "$($this.Snippets.Reset)"
    );

    [string]$accidentsStmt = $(
      "$($this.Snippets.Reset)" +
      "$($this.Labels.AccidentallyInAllSets -f $structuredParamName)" +
      "$($this.Snippets.Reset)$($this.Snippets.Ln)$($this.Labels.BulletedParamSet)$($quotedParamSetName)"
    );

    return $accidentsStmt;
  }

  [string] SyntaxStmt(
    [System.Management.Automation.CommandParameterSetInfo]$paramSet
  ) {
    #
    # the syntax can be processed by regex: (gcm command -syntax) -replace '\]? \[*(?=-|<C)',"`r`n "
    # this expression is used in the unit tests.

    [string]$source = $paramSet.ToString();
    [string]$structuredSyntax = $(
      "$($this.Snippets.Reset)Syntax: $($this.Snippets.Command)$($this.CommandName) $source"
    );

    $structuredSyntax = $this.Regex.Param.OptionalPos_A.Replace(
      $structuredSyntax, $this.Formats.OptionalPos_A);

    $structuredSyntax = $this.Regex.Param.OptionalNamed_B.Replace(
      $structuredSyntax, $this.Formats.OptionalNamed_B);

    $structuredSyntax = $this.Regex.Param.ManNamed_C.Replace(
      $structuredSyntax, $this.Formats.MandatoryNamed_C);

    $structuredSyntax = $this.Regex.Param.ManPos_D.Replace(
      $structuredSyntax, $this.Formats.MandatoryPos_D);

    $structuredSyntax = $this.Regex.Param.Switch.Replace(
      $structuredSyntax, $this.Formats.OptionalSwitch);

    # We need to process Mandatory switch parameters, which are of the form
    # -Param. However, this pattern is way too generic, so we can't identify
    # by applying a regex on the syntax. Instead, we need to query the parameter
    # set's parameters to find them and colourise them directly.
    #
    [PSCustomObject[]]$resultSet = $($paramSet.Parameters | Where-Object {
        ($_.Name -NotIn $this.CommonParamSet) -and
        ($_.IsMandatory) -and ([string]$_.ParameterType -eq 'switch')
      });

    if ($resultSet -and ($resultSet.Count -gt 0)) {
      [string[]]$names = $resultSet.Name;

      $names | ForEach-Object {
        $expression = "\-$_[\s|$]";
        $structuredSyntax = $($structuredSyntax -replace $expression, $(
            # -Param
            #
            $this.Snippets.Switch + '-' + $this.Snippets.Mandatory + $_ + ' '
          ))
      }
    }

    # NB: this is a straight string replace, not regex replace
    #
    $structuredSyntax = $structuredSyntax.Replace('[<CommonParameters>]',
      "$($this.Snippets.Punct)[<$($this.Snippets.Default)CommonParameters$($this.Snippets.Punct)>]"
    );

    return $structuredSyntax;
  }

  [string] DuplicateParamSetStmt(
    [System.Management.Automation.CommandParameterSetInfo]$firstSet,
    [System.Management.Automation.CommandParameterSetInfo]$secondSet
  ) {
    # ----> Parameter sets [command-name]: 'first' and 'second' have equivalent sets of parameters
    #
    [string]$structuredDuplicateParamSetStmt = $(
      "$($this.Snippets.Reset)$([string]::new(' ', $this.TableOptions.Chrome.Indent))" +
      "$($this.Signals['BULLET-B'].Value) Parameter Sets " +
      "$($this.Snippets.Reset)[$($this.Snippets.Command)$($this.CommandName)$($this.Snippets.Reset)]: " +
      "$($this.Snippets.Punct)'" +
      "$($this.Snippets.ParamSetName)$($firstSet.Name)$($this.Snippets.Punct)'$($this.Snippets.Reset) and " +
      "$($this.Snippets.Punct)'" +
      "$($this.Snippets.ParamSetName)$($secondSet.Name)$($this.Snippets.Punct)' " +
      "$($this.Snippets.Reset) have equivalent sets of parameters:" +
      "$($this.Snippets.Ln)"
    );
    return $structuredDuplicateParamSetStmt;
  }

  [string] QuotedNameStmt([string]$nameSnippet) {
    return $this.QuotedNameStmt($nameSnippet, '${name}', "'");
  }

  [string] QuotedNameStmt([string]$nameSnippet, [string]$name) {
    return $this.QuotedNameStmt($nameSnippet, $name, "'");
  }

  [string] QuotedNameStmt([string]$nameSnippet, [string]$name, [string]$open) {
    [string]$close = if ([syntax]::CloseBracket.ContainsKey($open)) {
      [syntax]::CloseBracket[$open];
    }
    else {
      $open;
    }

    [string]$nameStmt = $(
      $($this.Snippets.Punct) + $open + $nameSnippet + $name + $($this.Snippets.Punct) + $close
    );
    return $nameStmt;
  }

  [string] InvokeWithParamsStmt (
    [System.Management.Automation.CommandParameterSetInfo]$paramSet,
    [string[]]$invokeParams
  ) {
    [System.Text.StringBuilder]$buildR = [System.Text.StringBuilder]::new();

    [int]$count = 0;
    $invokeParams | ForEach-Object {
      [string]$paramName = $_;
      [array]$params = $paramSet.Parameters | Where-Object Name -eq $paramName;

      if ($params.Count -eq 1) {
        [System.Management.Automation.CommandParameterInfo]$parameterInfo = $params[0];
        [string]$paramSnippet = $this.ResolveParameterSnippet($parameterInfo);

        $null = $buildR.Append($this.QuotedNameStmt($paramSnippet, $parameterInfo.Name));

        if ($count -lt ($invokeParams.Count - 1)) {
          $null = $buildR.Append($this.Snippets.Comma);
        }
      }
      $count++;
    }

    return $buildR.ToString();
  }

  [string] Indent([int]$units) {
    return [string]::new(' ', $this.TableOptions.Chrome.Indent * $units);
  }

  [string] Fold([string]$text, [string]$textSnippet, [int]$width, [int]$margin) {
    [System.Text.StringBuilder]$buildR = [System.Text.StringBuilder]::new();
    $null = $buildR.Append($textSnippet);

    [string[]]$split = $text -split ' ';
    [int]$tokenNoCurrentLine = 0;
    [string]$line = [string]::new(' ', $margin);
    [int]$space = 1;
    foreach ($token in $split) {
      if ((($line.Length + $token.Length + $space) -lt ($width - $margin)) -or ($tokenNoCurrentLine -eq 0)) {
        # Current token will fit on the current line so let's add it. The only exception is
        # if the current token is very large and breaches the width/margin limit by itself
        # (ie tokenNo is 0), then we have no choice other than to breach the limit anyway.
        # I suppose an alternative would be just to fold this token by inserting a dash.
        #
        $line += "$token "; # use of += ok here, because its the core of the algorithm.
        $tokenNoCurrentLine++;
      }
      else {
        # Current token doesn't fit, so let's start a new line
        #
        $null = $buildR.Append($(
            "$($line)$($this.Snippets.Ln)"
          ));
        $line = [string]::new(' ', $margin);
        $line += "$token "; # use of += ok here, because its the core of the algorithm.
        $tokenNoCurrentLine = ($tokenNoCurrentLine -eq 0) ? 1 : 0;
      }
    }
    $null = $buildR.Append($(
        "$($line)$($this.Snippets.Ln)$($this.Snippets.Reset)"
      ));

    return $buildR.ToString();
  }
}
Export-ModuleMember -Variable LoopzHelpers, LoopzUI, Loopz

Export-ModuleMember -Alias remy, greps, esc, moma, ife, Foreach-FsItem, imdt, Mirror-Directory, itd, Traverse-Directory, wife, Decorate-Foreach, rgcos, shire, ships, sharp

Export-ModuleMember -Function Rename-Many, Select-Patterns, Show-Signals, Update-CustomSignals, Add-Appendage, Format-Escape, Get-FormattedSignal, Get-PaddedLabel, Get-Signals, Initialize-ShellOperant, Move-Match, New-BootStrap, new-RegularExpression, resolve-PatternOccurrence, Select-FsItem, Select-SignalContainer, Split-Match, Update-GroupRefs, Update-Match, Invoke-ForeachFsItem, Invoke-MirrorDirectoryTree, Invoke-TraverseDirectory, Format-BooleanCellValue, Format-StructuredLine, Get-AsTable, Get-SyntaxScheme, Get-TableDisplayOptions, Show-AsTable, Show-Header, Show-Summary, Write-HostFeItemDecorator, edit-RemoveSingleSubString, Get-FieldMetaData, Get-InverseSubString, Get-IsLocked, Get-LargestLength, Get-PartitionedPcoHash, get-PlatformName, Get-PsObjectField, Get-UniqueCrossPairs, invoke-ByPlatform, New-DryRunner, New-Syntax, Register-CommandSignals, resolve-ByPlatform, Show-InvokeReport, Show-ParameterSetInfo, Show-ParameterSetReport, Test-ContainsAll, Test-HostSupportsEmojis, Test-Intersect, Test-IsAlreadyAnchoredAt, Test-IsFileSystemSafe

# Custom Module Initialisation
#
$Loopz.Signals = $(Initialize-Signals)