Elizium.PoShLog.psm1

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

function Build-PoShLog {
  <#
  .NAME
    Build-PoShLog
 
  .SYNOPSIS
    Create a change log
 
  .DESCRIPTION
    Before running, the user needs to eject a config (json), then modify it for the repo's
  needs. There are a few pre-defined configs from which to choose, that customises the
  appearance of the generated change log. Most of the options are cosmetic with a few
  that define the structure. Also, the user can keep the default regular expressions (which
  conform to keep-a-changelog). One of the properties of a good change log are that
  commits should be grouped together based upon change type/scope/commit-type/breaking
   changes. This change log implementation performs groupings based on all of these
  categories. The quality of the generated changelog depends heavily on the quality of
  the commit messages in the repo.
    However, one of the reasons for this implementation is in recognition of the fact that
  a repo may not have commits with messages that are in accordance with keep-a-changelog.
  If a repo's commits can be easily represented by alternative regular expression(s),
  then the user can update the options file with these additional patterns. More than 1
  pattern can be specified, but the more restrictive patterns should be specified first.
    Also, a repo can have many un-squashed commits, which is generally not advised, but
  again, in recognition of this state of a repo, there is a squash facility built into
  the change log. That is not to say that the commits in the repo are squashed, rather
  they are squashed just in the change log by a provided regular expression. In the options
  config, there is a "SquashBy" setting under "Selection" and the default specifies an
  "issue" number so that commits with the same issue number are grouped into a single entry
  in the change log. By default, these squashed entries are marked out so they are easily
  identified by the user and can be changed accordingly.
    The user can if they want, just use the generated output as is. However, this is not
  the intention behind this command. Generating output, is just the first step in creating
  a useful change log meaning extra curation is required of the user to ensure the items
  marked as Squashed for example, are cleaned up (the end user is probably not interested
  in whether an item is squashed or not).
    An alternative way to change the appearance of the change log is the use of emojis
  which can really help in identifying items generated in the log and the nature of
  that change. The user is welcome to change the emojis used by updating the options
  file.
    The user also needs to decide how the grouping of commits is to be performed. This is
  controlled by the "GroupBy" setting. The default is "scope/type/change/break". This
  GroupBy can have any combination of these 4 values and the path doesn't even have to
  have all of those values. Eg, the user could if they wanted specify just 'scope' and in this
  scenario, all commits are just grouped by the scope and nothing else. Each leg of the
  GroupBy path, eg 'scope' is assigned a heading type. So for the default of
  "scope/type/change/break", scope appears as a H3 heading, type as a H4, change as a
  H5 and break as a H6. No more than four segments can be defined and will result in
  an error. It is recommended that the user tries all the defaults with/without
  emojis to see which one best suits their needs and to try alternative values given
  for the "GroupBy" value to see how entries are organised.
    If the repo is a large one, the user probably doesn't want every single commit to appear
  as its own entry in the commit log. This will make for a correspondingly large commit log.
  It is recommended that the user specifies some exclusion criteria in the excludes regular
  expressions (there are none by default).
    To see a detailed explanation of the options config, the user should consult
  [Build ChangeLog](Elizium.PoShLog/README.md).
 
  .LINK
    https://eliziumnet.github.io/PoShLog/
 
  .PARAMETER Name
    The name of the config to use. The current list of predefined configs are 'Alpha',
  'Elizium' and 'Zen' (more may be added in future). However, the user can specify a
  custom name, in which case a custom default options file will be generated under that
  name. In this case, the user MUST update its contents as it is not complete. It should
  be noted that the name is just a logical identifier. A file name is generated from
  this logical name. As configs are repo specific, they are created inside the repo and
  should be committed into source control.
 
  .PARAMETER From
    Is a Tag value. Commits that come after the date associated with this tag are selected.
  If specified, overrides the From value in the options config under "Tags.From" setting.
 
  .PARAMETER Until
    Is a Tag value. Commits that come before the date associated with this tag are selected.
  If specified, overrides the Until value in the options config under "Tags.Until" setting.
 
  .PARAMETER Unreleased
    switch to indicate the build log should only contain entries that represent commits
  that have not yet been released, ie changes that are coming in the next release.
 
  .PARAMETER Eject
    switch to indicate a config should be ejected for use. A change log is not generated
  when a config is ejected as it is assumed the user needs to make adjustments to the
  ejected options config file.
 
  .PARAMETER PassThru
    switch to indicate that the generated markdown content is to be sent through the pipeline
  instead of being saved to a file.
 
  .PARAMETER Emoji
    switch to control whether an emoji based config is ejected. When generating a change
  log, indicates that the emoji version of the config should be used.
 
  .EXAMPLE 1
 
  Build-PoShLog -name 'Alpha' -Eject -Emoji
 
  Eject 'Alpha' emojis options config into the repo under <root>/PoShLog/
  as "Alpha-emoji-poshlog.options.json"
 
  .EXAMPLE 2
 
  Build-PoShLog -name 'Zen' -Eject
 
  Eject 'Zen' options config without emojis into the repo under <root>/PoShLog/
  as "Zen-poshlog.options.json"
 
  .EXAMPLE 3
 
  Build-PoShLog -name 'Zen'
 
  Build a change log using the pre-defined Zen config without emojis.
 
  .EXAMPLE 4
 
  Build-PoShLog -name 'foo'
 
  Build a change log using a custom 'foo' config. If the 'foo' config does not exist
  a default config is used. The user needs to update the config and re-run.
 
  .Example 5
 
  Build-PoShLog -name 'Zen' -From '1.0.0 -Until '3.0.0'
 
  Build a change log that contains for commits in releases within a specified range.
 
  .Example 6
 
  Build-PoShLog -name 'Zen' -Unreleased
 
  Build a change log that contains unreleased commits only.
  #>

  [CmdletBinding(DefaultParameterSetName = 'CreateLog')]
  [Alias('plog')]
  param(
    [Parameter(Position = 1)]
    [Alias('n')]
    [string]$Name = 'Alpha',

    [Parameter(ParameterSetName = 'CreateLog', Position = 2)]
    [Alias('f')]
    [string]$From,

    [Parameter(ParameterSetName = 'CreateLog', Position = 3)]
    [Alias('un')]
    [string]$Until,

    [Parameter(ParameterSetName = 'CreateLogUnreleased')]
    [Alias('ur')]
    [switch]$Unreleased,

    [Parameter(ParameterSetName = 'CreateLog')]
    [Alias('p')]
    [switch]$PassThru,

    [Parameter(ParameterSetName = 'EjectConfig', Mandatory)]
    [Alias('j')]
    [switch]$Eject,
    
    [Parameter()]
    [Alias('e')]
    [switch]$Emoji,

    [Parameter()]
    [switch]$Test
  )
  [PSCustomObject]$optionsInfo = [PSCustomObject]@{
    PSTypeName    = 'PoShLog.PoShLog.OptionsInfo';
    #
    Base          = '-poshlog.options';
    DirectoryName = [PoShLogProfile]::DIRECTORY;
  }
  if ($Test.IsPresent) {
    [string]$rootPath = $($env:PesterTestDrive ?? $env:temp);
    $optionsInfo | Add-Member -NotePropertyName 'Root' -NotePropertyValue $rootPath;
  }

  [PoShLogOptionsManager]$manager = New-PoShLogOptionsManager -OptionsInfo $optionsInfo;
  [Scribbler]$scribbler = New-Scribbler -Test:$Test.IsPresent;

  [hashtable]$signals = $(Get-Signals);    
  [string]$chogSignal = Get-FormattedSignal -Name 'PLOG' -EmojiOnly -Signals $signals -EmojiOnlyFormat '{0}';
  [string]$ejectSignal = Get-FormattedSignal -Name 'EJECT' -EmojiOnly -Signals $signals -EmojiOnlyFormat '{0}';

  [string]$lnSn = $scribbler.Snippets('Ln');
  [string]$resetSn = $scribbler.Snippets('Reset');
  [string]$actionSn = $($resetSn + $scribbler.Snippets('blue'));
  [string]$nameSn = $scribbler.Snippets('red');
  [string]$pathSn = $scribbler.Snippets('cyan');
  [string]$requestSn = $($resetSn + $scribbler.Snippets('green'));
  [string]$quoteSn = $($resetSn + $scribbler.Snippets('yellow'));

  [string]$nameFragment = $(
    "$($quoteSn)'$($nameSn)$($name)$($quoteSn)'"
  );

  [string]$pathFragment = $(
    "$($quoteSn)'$($pathSn)$($manager.FullPath($name, $Emoji.IsPresent))$($quoteSn)'"
  );

  if (@('CreateLog', 'CreateLogUnreleased') -contains $PSCmdlet.ParameterSetName) {
    [PSCustomObject]$options = $manager.FindOptions($Name, $Emoji.IsPresent);

    if ($manager.Found) {
      # specified parameters always override config
      #
      if ($Unreleased.IsPresent) {
        $options.Selection.Tags | Add-Member -NotePropertyName 'Unreleased' -NotePropertyValue $true -Force;

        if (($options.Selection.Tags)?.From) {
          $options.psobject.properties.remove('From');
        }

        if (($options.Selection.Tags)?.Until) {
          $options.psobject.properties.remove('Until');
        }
      }
      else {
        if ($PSBoundParameters.ContainsKey('From')) {
          $options.Selection.Tags | Add-Member -NotePropertyName 'From' -NotePropertyValue $from -Force;

          if (($options.Selection.Tags)?.Unreleased) {
            $options.psobject.properties.remove('Unreleased');
          }
        }

        if ($PSBoundParameters.ContainsKey('Until')) {
          $options.Selection.Tags | Add-Member -NotePropertyName 'Until' -NotePropertyValue $until -Force;

          if (($options.Selection.Tags)?.Unreleased) {
            $options.psobject.properties.remove('Unreleased');
          }
        }
      }
      [PoShLog]$changeLog = New-PoShLog -Options $options;

      [string]$content = $changeLog.Build();
      [string]$base = $options.Output.Base;
      [string]$outputFile = $(
        $base + '-' + $Name + $($Emoji.IsPresent ? '-emoji' : [string]::Empty) + '.md'
      );

      if ($PassThru.IsPresent) {
        $content;
      }
      else {
        [string]$fullPath = $manager.DirectoryPath($outputFile);
        $changeLog.Save($content, $fullPath);
      }
    }
    else {
      [string]$action = $(
        "$($chogSignal) $($actionSn)Created new options config for " +
        "$($nameFragment)$($actionSn) at $($pathFragment)" +
        "$($Emoji.IsPresent ? "$($actionSn), with emojis" : [string]::Empty).$($lnSn)"
      );
      $scribbler.Scribble($action);

      [string]$request = $(
        "$($requestSn)--> Please review the options and re-run.$($lnSn)"
      );      
      $scribbler.Scribble($request);
    }
  }
  elseif ($PSCmdlet.ParameterSetName -eq 'EjectConfig') {
    [PSCustomObject]$options = $manager.Eject($Name, $Emoji.IsPresent);

    [string]$action = $(
      "$($chogSignal) $($actionSn)Ejected $($ejectSignal)" +
      "$($nameFragment)$($actionSn) options config to $($pathFragment)$($actionSn).$($lnSn)"
    );
    $scribbler.Scribble($action);

    [string]$request = $(
      "$($requestSn)--> Please update options (Scopes/Tags)" +
      " for $($nameFragment)$($requestSn).$($lnSn)"
    );
    $scribbler.Scribble($request);
  }

  $scribbler.Flush();
}

function New-PoShLog {
  <#
  .NAME
    New-PoShLog
 
  .SYNOPSIS
    Create PoShLog instance
 
  .DESCRIPTION
    Factory function for PoShLog instances.
 
  .LINK
    https://eliziumnet.github.io/klassy
 
  .PARAMETER Options
    PoShLog options
  #>

  [OutputType([PoShLog])]
  param(
    [PSCustomObject]$Options
  )
  [ProxyGit]$proxy = [ProxyGit]::New();
  [SourceControl]$git = [Git]::new($Options, $proxy);
  [GroupByImpl]$grouper = [GroupByImpl]::new($Options);
  [MarkdownPoShLogGenerator]$generator = [MarkdownPoShLogGenerator]::new(
    $Options, $git, $grouper
  );
  [PoShLog]$instance = [PoShLog]::new($Options, $git, $grouper, $generator);

  $instance.Init();
  return $instance;
}

function New-PoShLogOptionsManager {
  param(
    [Parameter()]
    [PSCustomObject]$OptionsInfo,

    [Parameter()]
    [ProxyGit]$Proxy
  )

  [PoShLogOptionsManager]$manager = [PoShLogOptionsManager]::new(
    $Proxy ?? [ProxyGit]::new(),
    $OptionsInfo
  );
  $manager.Init();

  return $manager;
}

<#
  .NAME
    New-ProxyGit
 
  .SYNOPSIS
    Create a new GitProxy instance
 
  .DESCRIPTION
    Factory function for PoShLog instances.
 
  .LINK
    https://eliziumnet.github.io/klassy
 
  .PARAMETER Overrides
    Hashtable containing function overrides required for unit testing. The
  members on the proxy that cn be overridden are:
  'ReadHeadDate', 'ReadLogTags', 'ReadLogRange', 'ReadRemote' and 'ReadRoot'.
 
  .EXAMPLE
 
  EXAMPLE 1: Override 'ReadHeadDate' method on the proxy
 
  [hashtable]$script:overrides = @{
    'ReadHeadDate' = [scriptblock] {
      return $_headTagData.TimeStamp;
    }
  }
  [ProxyGit]$proxy = New-ProxyGit -Overrides $overrides;
 
  .EXAMPLE
 
  EXAMPLE 2: Override multiple methods 'ReadHeadDate' and 'ReadLogRange' method on the proxy
 
  [hashtable]$script:overrides = @{
    'ReadLogRange' = [scriptblock] {
      # do test stuff
    }
 
    'ReadLogRange' = [scriptblock] {
      param(
        [Parameter()]
        [string]$range,
 
        [Parameter()]
        [string]$format
      )
      # do test stuff
    }
    [ProxyGit]$proxy = New-ProxyGit -Overrides $overrides;
  }
  #>

function New-ProxyGit {
  [OutputType([ProxyGit])]
  param(
    [Parameter()]
    [hashtable]$Overrides = @{}
  )
  [ProxyGit]$proxy = [ProxyGit]::new();

  if ($Overrides.Count -gt 0) {
    [array]$members = ($proxy | Get-Member -MemberType Property).Name;

    $Overrides.PSBase.Keys | ForEach-Object {
      [string]$name = $_;

      if ($members -contains $name) {
        $proxy.$name = $Overrides[$name];
      }
      else {
        throw [System.Management.Automation.MethodInvocationException]::new(
          "'$name' does not exist on Proxy"
        );
      }
    }

  }

  return $proxy;
}

# === [ SourceControl ] ========================================================
#
class SourceControl {
  [PSCustomObject]$Options;
  [PSCustomObject[]]$AllTagsWithHead;
  [PSCustomObject[]]$AllTagsWithoutHead;
  hidden [DateTime]$_headDate; # date of last commit
  hidden [DateTime]$_lastReleaseDate; # date of last release (can be null if no releases)
  static [int]$DEFAULT_COMMIT_ID_SIZE = 7;
  [int]$_commitIdSize;

  SourceControl([PSCustomObject]$options) {
    $this.Options = $options;
  }

  [void] Init([boolean]$descending) {
    [boolean]$includeHead = $true;
    $this.AllTagsWithHead = $this.ReadSortedTags($includeHead, $descending);

    $includeHead = $false;
    $this.AllTagsWithoutHead = $this.ReadSortedTags($includeHead, $descending);

    $this._commitIdSize = try {
      [int]$size = [int]::Parse($this.Options.SourceControl.CommitIdSize);
      
      $($size -in 7..40) ? $size : [SourceControl]::DEFAULT_COMMIT_ID_SIZE;
    }
    catch {
      [SourceControl]::DEFAULT_COMMIT_ID_SIZE;
    }
  }

  [PSCustomObject[]] GetSortedTags([boolean]$includeHead) {
    return $includeHead ? $this.AllTagsWithHead : $this.AllTagsWithoutHead;
  }

  [PSCustomObject[]] ReadGitTags([boolean]$includeHead) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (SourceControl.ReadGitTags)');
  }

  [string] ReadRemoteUrl() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (SourceControl.ReadRemoteUrl)');
  }

  [string] ReadRootPath() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (SourceControl.ReadRootPath)');
  }

  [PSCustomObject[]] ReadSortedTags([boolean]$includeHead, [boolean]$descending) {

    [PSCustomObject[]]$unsorted = $this.ReadGitTags($includeHead);
    [PSCustomObject[]]$sorted = $unsorted | Sort-Object -Property 'Date' -Descending:$descending;

    return $sorted ?? @();
  }

  [PSCustomObject[]] ReadGitCommitsInRange(
    [string]$Format,
    [string]$Range,
    [string[]]$Header,
    [string]$Delim
  ) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (SourceControl.ReadGitCommitsInRange)');
  }

  [DateTime] GetTagDate ([string]$Label) {
    [PSCustomObject]$foundTagInfo = $this.GetSortedTags($true) | `
      Where-Object { $_.Label -eq $Label }

    if (-not($foundTagInfo)) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        "SourceControl.GetTagDate: Tag: '$Label' not found");
    }

    return $foundTagInfo.Date;
  }

  [DateTime] GetLastReleaseDate() {
    [PSCustomObject[]]$sortedTags = $this.GetSortedTags($false);

    [DateTime]$releaseDate = if ($sortedTags.Count -gt 0) {
      $sortedTags[0].Date;
    }
    else {
      $null;
    }

    return $releaseDate;
  }

  [string[]] GetTagRange([regex]$RangeRegex, [string]$Range) {
    [System.Text.RegularExpressions.MatchCollection]$mc = $RangeRegex.Matches($Range);

    if (-not($rangeRegex.IsMatch($Range))) {
      throw "bad range: '$Range'";
    }
    [System.Text.RegularExpressions.Match]$m = $mc[0];
    [System.Text.RegularExpressions.GroupCollection]$groups = $m.Groups;

    [string]$from = $groups['from'];
    [string]$until = $groups['until'];

    return $from, $until;
  }

  # Returns: [PSTypeName('PoShLog.TagInfo')][array]
  #
  [PSCustomObject[]] processTags ([PSCustomObject[]]$gitTags, [boolean]$includeHead) {
    [regex]$tagRegex = "(?<dt>[^\(]+)\(tag: (?<tag>[^\)]+)\)";
    [regex]$versionRegex = [regex]::new("(?<ver>\d\.\d\.\d)");

    [array]$result = foreach ($prettyTag in $gitTags) {
      Write-Debug "SourceControl.processTags - TAG: '$prettyTag'";

      if ($tagRegex.IsMatch($prettyTag)) {
        [System.Text.RegularExpressions.MatchCollection]$mc = $tagRegex.Matches($prettyTag);
        [System.Text.RegularExpressions.Match]$m = $mc[0];
        [System.Text.RegularExpressions.GroupCollection]$groups = $m.Groups;

        [string]$dt = $groups['dt'].Value.Trim();
        [string]$tag = $groups['tag'].Value;
        [DateTime]$date = [DateTime]::Parse($dt)

        [PSCustomObject]$tagInfo = [PSCustomObject]@{
          PSTypeName = 'PoShLog.TagInfo';
          Label      = $tag;
          Date       = $date;
        }

        if ($versionRegex.IsMatch($tag)) {
          [System.Text.RegularExpressions.MatchCollection]$mc = $versionRegex.Matches($tag);
          [string]$version = $mc[0].Value;
          $tagInfo | Add-Member -NotePropertyName 'Version' -NotePropertyValue $version;
        }

        $tagInfo;
      }
      else {
        throw [System.Management.Automation.MethodInvocationException]::new(
          "processTags: Bad tag found: '$($prettyTag)'");
      }
    }

    if ($includeHead -and $this._headDate) {
      $result = $result += [PSCustomObject]@{
        PSTypeName = 'PoShLog.TagInfo';
        Label      = 'HEAD';
        Date       = $this._headDate;
      }
    }

    return $result;
  } # processTags
} # SourceControl

# === [ Git ] ==================================================================
#
class Git : SourceControl {
  # Ideally, _gitCi should be used to execute all git commands. However, doing so and
  # passing in the parameters is tricky, which is the reason why git is invoked directly,
  # until the correct way to invoke with arguments has been determined.
  # The invoke options are:
  # - Call Op: & "path/blah.exe" "param1" "param2"
  # - Invoke-Command
  # - Invoke-Expression
  # - Invoke-Item
  #
  # See also:
  # https://social.technet.microsoft.com/wiki/contents/articles/7703.powershell-running-executables.aspx
  #
  hidden [System.Management.Automation.CommandInfo]$_gitCi;

  # [ProxyGit], why the hell doesn't strong typing work
  [object]$Proxy;

  # [ProxyGit]$proxy
  Git([PSCustomObject]$options, [object]$proxy): base($options) {
    $this.Proxy = $proxy;

    # Just check that git is available
    # TODO: check the digital signature
    # https://mcpmag.com/articles/2018/07/25/file-signatures-using-powershell.aspx
    #
    $this._gitCi = Get-Command 'git' -ErrorAction Stop;
    if (-not($this._gitCi -and ($this._gitCi.CommandType -eq
          [System.Management.Automation.CommandTypes]::Application))) {

      throw [System.Management.Automation.MethodInvocationException]::new(
        'git not found');
    }

    # %ai = author date, ISO 8601-like format
    # eg: '2021-04-19 18:20:49 +0100'
    #
    [string]$head = $this.Proxy.HeadDate();
    $this._headDate = [DateTime]::Parse($head);    
  } # ctor.Git

  [PSCustomObject[]] ReadGitTags([boolean]$includeHead) {
    # The 'i' in '%ci' wraps the date inside brackets and this is reflected in the regex pattern
    # %d: ref names
    # eg: '2021-04-19 18:17:15 +0100 (tag: 3.0.2)'
    #
    [array]$tags = $this.Proxy.LogTags();
    return $this.processTags($tags, $includeHead);
  } # ReadGitTags

  # Returns: [PSTypeName('PoShLog.CommitInfo')][]
  #
  [PSCustomObject[]] ReadGitCommitsInRange(
    [string]$Format,
    [string]$Range,
    [string[]]$Header,
    [string]$Delim
  ) {
    Write-Debug "ReadGitCommitsInRange: RANGE: '$($Range)', FORMAT: '$($Format)'.";

    [array]$commitContent = $this.Proxy.LogRange($Range, $Format);
    [array]$result = $commitContent | ConvertFrom-Csv -Delimiter $Delim -Header $Header;

    $result | Where-Object { $null -ne $_.CommitId } | ForEach-Object {
      Add-Member -InputObject $_ -PassThru -NotePropertyMembers @{
        PSTypeName = 'PoShLog.CommitInfo';
        FullHash   = $_.CommitId;
      }
    } | ForEach-Object {
      $_.CommitId = $_.CommitId.SubString(0, $this._commitIdSize);
      $_.Date = [DateTime]::Parse($_.Date); # convert date
    }

    return $result;
  } # ReadGitCommitsInRange

  [string] ReadRemoteUrl() {
    [string]$url = $this.Proxy.Remote();
    if ($url.EndsWith('/')) {
      $url = $url.Substring(0, $($url.Length - 1));
    }
    return $url
  }

  [string] ReadRootPath() {
    return $this.Proxy.Root();
  }
} # Git

function readHeadDate {
  [OutputType([string])]
  param()
  return $(git log -n 1 --format=%ai) ?? [string]::Empty;
}

function readLogTags {
  [OutputType([array])]
  param()
  return $((git log --tags --simplify-by-decoration --pretty="format:%ci %d") -match 'tag:') ?? @();
}

function readLogRange {
  [OutputType([array])]
  param(
    [Parameter()]
    [string]$range,

    [Parameter()]
    [string]$format
  )
  return $((git log $range --format=$format) ?? @());
}

function readRemote {
  [OutputType([string])]
  param()
  return $((git remote get-url origin) -replace '\.git$') ?? [string]::Empty;
}

function readRoot {
  [OutputType([string])]
  param()
  return $(git rev-parse --show-toplevel) ?? [string]::Empty;
}

# === [ ProxyGit ] ===========================================================
#
class ProxyGit {
  ProxyGit() {}

  # All these are designed to be overridden by tests
  #
  [scriptblock]$ReadHeadDate = $function:readHeadDate;
  [scriptblock]$ReadLogTags = $function:readLogTags;
  [scriptblock]$ReadLogRange = $function:readLogRange;
  [scriptblock]$ReadRemote = $function:readRemote;
  [scriptblock]$ReadRoot = $function:readRoot;

  [string] HeadDate() {
    return $this.ReadHeadDate.InvokeReturnAsIs();
  }

  [array] LogTags() {
    return $this.ReadLogTags.InvokeReturnAsIs();
  }

  [array] LogRange([string]$range, [string]$format) {
    return $this.ReadLogRange.InvokeReturnAsIs($range, $format);
  }

  [string] Remote() {
    return $this.ReadRemote.InvokeReturnAsIs();
  }

  [string] Root() {
    return $this.ReadRoot.InvokeReturnAsIs();
  }
}

# === [ PoShLog ] ============================================================
#
class PoShLog {
  [PSCustomObject]$Options;
  [SourceControl]$SourceControl;
  [boolean]$IsDescending;
  [PSCustomObject[]]$TagsInRangeWithHead;
  [PSCustomObject[]]$AllTagsWithHead;

  hidden [regex]$_squashRegex;
  hidden [GroupBy]$_grouper;
  hidden [PoShLogGenerator]$_generator;

  PoShLog([PSCustomObject]$options,
    [SourceControl]$sourceControl,
    [GroupBy]$grouper,
    [PoShLogGenerator]$generator) {

    $this.Options = $options;
    $this.SourceControl = $sourceControl;
    $this._grouper = $grouper;
    $this._generator = $generator;

    $this.IsDescending = $true;
    $this._grouper.SetDescending($this.IsDescending);
    $this._generator.SetDescending($this.IsDescending);

    $this._squashRegex = if (($this.Options.Selection)?.SquashBy `
        -and -not([string]::IsNullOrEmpty($this.Options.Selection?.SquashBy))) {
      [regex]::new($this.Options.Selection.SquashBy);
    }
    else {
      $null;
    }

    $sourceControl.Init($this.IsDescending);
  } # ctor.PoShLog

  [void] Init() {
    $this.AllTagsWithHead = $this.SourceControl.GetSortedTags($true);
    $this.TagsInRangeWithHead = $this.GetTagsInRange();

    if (($this.Options.Selection.Tags)?.From) {
      if (-not($this.TagIsValid($this.Options.Selection.Tags.From))) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "PoShLog.Init: From tag '$($this.Options.Selection.Tags.From)'" +
            " does not exist in this repo"
          )
        );
      }
    }

    if (($this.Options.Selection.Tags)?.Until) {
      if (-not($this.TagIsValid($this.Options.Selection.Tags.Until))) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "PoShLog.Init: Until tag '$($this.Options.Selection.Tags.Until)'" +
            " does not exist in this repo"
          )
        );
      }
    }

    if (($this.Options.Selection.Tags)?.From -and ($this.Options.Selection.Tags)?.Until) {
      if (-not($this.TagRangeIsValid(
            $this.Options.Selection.Tags.From, $this.Options.Selection.Tags.Until
          ))) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "PoShLog.Init: From tag: '$($this.Options.Selection.Tags.From)'" +
            " and Until tag: '$($this.Options.Selection.Tags.Until)'" +
            " have been specified the wrong way round. Swap the values and try again."
          )
        );
      }
    }
  }

  [boolean] TagIsValid([string]$label) {
    $found = $this.AllTagsWithHead | Where-Object {
      $_.Label -eq $label;
    }

    if (($this.Options.Selection.Tags)?.Unreleased) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        $("PoShLog.Init: From tag 'Unreleased' can not be specified with '$label'")
      );
    }

    return ($null -ne $found);
  }

  [boolean] TagRangeIsValid([string]$fromLabel, [string]$untilLabel) {
    [PSCustomObject]$fromTag = $($this.AllTagsWithHead | Where-Object {
        $_.Label -eq $fromLabel;
      })?[0];

    [PSCustomObject]$untilTag = $($this.AllTagsWithHead | Where-Object {
        $_.Label -eq $untilLabel;
      })?[0];

    return [DateTime]::Compare($fromTag.Date, $untilTag.Date) -lt 0;
  }

  [string] Build() {
    [array]$releases = $this.composePartitions();
    [object]$template = $this.Options.Output.Template;
    [string]$content = $this._generator.Generate(
      $releases, $template, $this.TagsInRangeWithHead
    );

    return $content;
  }

  [void] Save([string]$content, [string]$fullPath) {
    Set-Content -LiteralPath $fullPath -Value $content;
  }

  # Return: [PSTypeName('PoShLog.PartitionedRelease')][array]
  #
  [PSCustomObject[]] composePartitions() {
    [hashtable]$releases = $this.processCommits();

    return $this._grouper.Partition($releases, $this.AllTagsWithHead);
  }

  # Returns: ('PoShLog.CommitInfo')[]
  #
  [PSCustomObject[]] GetTagsInRange() {
    [scriptblock]$whereTagsInRange = if (($this.Options.Selection.Tags)?.From -and
      ($this.Options.Selection.Tags)?.Until) {

      [scriptblock] {
        [string]$from = ($this.Options.Selection.Tags)?.From;
        [string]$until = ($this.Options.Selection.Tags)?.Until;

        [DateTime]$fromDate = $this.SourceControl.GetTagDate($from);
        [DateTime]$untilDate = $this.SourceControl.GetTagDate($until);

        $this.IsDescending ? $_.Date -ge $fromDate -and $_.Date -le $untilDate `
          : $_.Date -le $fromDate -and $_.Date -ge $untilDate;
      }
    }
    elseif (($this.Options.Selection.Tags)?.From) {
      [scriptblock] {
        [string]$from = ($this.Options.Selection.Tags)?.From;
        [DateTime]$fromDate = $this.SourceControl.GetTagDate($from);

        $this.IsDescending ? $_.Date -ge $fromDate : $_.Date -le $fromDate;
      }
    }
    elseif (($this.Options.Selection.Tags)?.Until) {
      [scriptblock] {
        [string]$until = ($this.Options.Selection.Tags)?.Until;
        [DateTime]$untilDate = $this.SourceControl.GetTagDate($until);

        $this.IsDescending ? $_.Date -le $untilDate : $_.Date -ge $untilDate;
      }
    }
    elseif (($this.Options.Selection.Tags)?.Unreleased) {
      [scriptblock] {
        [DateTime]$lastDate = $this.SourceControl.GetLastReleaseDate();

        if ($lastDate) {
          $this.IsDescending ? $_.Date -gt $lastDate : $_.Date -le $lastDate;
        }
        else {
          # There are no releases but there are commits, we should still be able
          # to build a change log
          #
          $true;
        }
      }      
    }
    else {
      [scriptblock] { $true } # => Select all tags by default
    }
    [PSCustomObject[]]$result = ($this.AllTagsWithHead | Where-Object $whereTagsInRange);

    return $result;
  } # GetTagsInRange

  # Returned releases are a hashtable keyed by tag label => [PSTypeName('PoShLog.SquashedRelease')]
  #
  [hashtable] processCommits() {

    # NB: WARNING, do not select the body; if it is multiline, then it will break
    # all of this, because the assumption is that 1 commit = 1 line of content
    #
    [string]$format = "%ai`t%H`t%an`t%s";
    [string[]]$header = @("Date", "CommitId", "Author", "Subject");
    [string]$delim = "`t";

    [hashtable]$releases = [ordered]@{}
    
    Write-Debug "=== [ processCommits: tags ($($this.TagsInRangeWithHead.Count)): '$($this.TagsInRangeWithHead.Label -join ', ')' ] ===";

    foreach ($tagInfo in $this.TagsInRangeWithHead) {
      [string]$until = $tagInfo.Label; 
      [PSCustomObject]$rangeInfo = $this.getRange($tagInfo, $this.AllTagsWithHead);

      # Attach an auxiliary Info field for later use
      #
      [array]$inRange = $this.SourceControl.ReadGitCommitsInRange(
        $format, $rangeInfo.Range, $header, $delim
      ) | ForEach-Object {
        Add-Member -InputObject $_ -NotePropertyName 'Info' -NotePropertyValue $null -PassThru;
      };

      $this.handleTagsInRange($releases, $until, $inRange);
    }

    return $releases;
  } # processCommits

  # $current is until and $from is synthetically set to the previous tag in sequence
  # tags in range eg:
  # |<-- most recent oldest -->|
  # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
  # HEAD, 3.0.2, 3.0.1, 3.0.0, 2.0.0, 1.2.0, 1.1.1, 1.1.0, 1.0.1, 1.0.0
  # curr, from
  #
  # ASSUMPTION: descending
  #
  [PSCustomObject] getRange ([PSCustomObject]$current, [PSCustomObject[]]$allTags) {
    [System.Predicate[PSCustomObject]]$isCurrent = [System.Predicate[PSCustomObject]] {
      param(
        [PSCustomObject]$item
      )
      $item.Label -eq $current.Label;
    }
    [int]$indexOfCurrent = [Array]::FindIndex($allTags, $isCurrent);
    [boolean]$isOldest = $indexOfCurrent -eq ($allTags.Count - 1);
    
    [PSCustomObject]$result = if ($current.Label -eq 'HEAD') {
      [string]$latest = ($allTags.Count -gt 1) ? $allTags[1].Label : 'HEAD';

      [PSCustomObject]@{
        Range = ($latest -eq 'HEAD') ? 'HEAD' : "$($latest)..HEAD";
        From  = $latest;
        Until = 'HEAD';
      }
    }
    elseif ($isOldest) {
      [PSCustomObject]@{
        Range = $current.Label;
        From  = [string]::Empty;
        Until = $current.Label;
      }
    }
    else {
      [string]$from = $allTags[$indexOfCurrent + 1].Label;

      [PSCustomObject]@{
        Range = "$($from)..$($current.Label)";
        From  = $from;
        Until = $current.Label;
      }
    }

    return $result;
  }

  [void] handleTagsInRange ([hashtable]$releases, [string]$until, [array]$inRange) {
    foreach ($com in $inRange) {
      [string]$displayDate = $com.Date.ToString('yyyy-MM-dd - HH:mm:ss');
      Write-Debug " ---> Label: '$until' PRE-FILTERED COUNT: '$($inRange.Count)' <---";
      Write-Debug " + '$($com.Subject)', DATE: '$($displayDate)'";
      Write-Debug " --------------------------";
      Write-Debug "";
    }

    [PSCustomObject]$squashed = $this.filterAndSquashCommits($inRange, $until);

    if ($squashed) {
      $releases[$until] = $squashed;
    }
  }

  # Filter and squash commits for a single release denoted by the Until label.
  # Returns a PSCustomObject instance with members:
  # - Squashed: hash indexed by issue no
  # - Commits: array of commits (no issue number, or squash not enabled)
  # - Label: until tag label for the release
  # - Dirty: array of unfiltered commits; release contains commits all filtered out.
  #
  # Returns: [PSTypeName('PoShLog.SquashedRelease')]
  #
  [PSCustomObject] filterAndSquashCommits([array]$commitsInRange, [string]$untilLabel) {
    [array]$filtered = $this.filter($commitsInRange, $untilLabel);
    [PSCustomObject]$result = if ($this._squashRegex) {

      [System.Collections.Generic.List[PSCustomObject]]$commitsWithoutIssueNo = `
        [System.Collections.Generic.List[PSCustomObject]]::new();

      [hashtable]$squashedHash = [ordered]@{}

      foreach ($commit in $filtered) {
        [System.Text.RegularExpressions.MatchCollection]$mc = $this._squashRegex.Matches(
          $commit.Subject
        );

        if ($mc.Count -gt 0) {
          [string]$issue = $mc[0].Groups['issue'];

          if ($squashedHash.ContainsKey($issue)) {
            $squashedItem = $squashedHash[$issue];
            $commit | Add-Member -NotePropertyName 'IsSquashed' -NotePropertyValue $true;

            # Do squash
            #
            if ($squashedItem -is [System.Collections.Generic.List[PSCustomObject]]) {
              $squashedItem.Add($commit); # => 3rd or more commit with this issue no
            }
            else {
              [System.Collections.Generic.List[PSCustomObject]]$newSquashedGroup = `
                [System.Collections.Generic.List[PSCustomObject]]::new();
              $squashedItem | Add-Member -NotePropertyName 'IsSquashed' -NotePropertyValue $true;
              $newSquashedGroup.Add($squashedItem); # => pre-existing first
              $newSquashedGroup.Add($commit); # => second commit
              $squashedHash[$issue] = $newSquashedGroup;
            }
          }
          else {
            $squashedHash[$issue] = $commit; # => first commit with this issue no
          }
        }
        else {
          if (($this.Options.Selection)?.IncludeMissingIssue -and
            $this.Options.Selection.IncludeMissingIssue) {
            $commitsWithoutIssueNo.Add($commit);
          }
        }
      }
      [PSCustomObject]$release = [PSCustomObject]@{
        PSTypeName = 'PoShLog.SquashedRelease';
        Squashed   = $squashedHash;
        Commits    = $commitsWithoutIssueNo;
        Label      = $untilLabel;
      }
      $release;
    }
    else {
      [PSCustomObject]$release = [PSCustomObject]@{
        PSTypeName = 'PoShLog.SquashedRelease';
        Commits    = $filtered;
        Label      = $untilLabel;
      }
      $release;
    }

    [boolean]$noSquashed = -not($result.Squashed) `
      -or ($result.Squashed -and ($result.Squashed.PSBase.Count -eq 0));

    [boolean]$noCommits = -not($result.Commits) `
      -or ($result.Commits -and ($result.Commits.Count -eq 0));

    if ($noSquashed -and $noCommits) {
      # No commits for release
      #
      $result = [PSCustomObject]@{
        PSTypeName = 'PoShLog.SquashedRelease';
        Dirty      = $commitsInRange;
        Label      = $untilLabel;
      };
    }

    return $result;
  } # filterAndSquashCommits

  [array] filter([array]$commits, [string]$untilLabel) {

    [regex[]]$includes = $this._grouper.BuildIncludes();
    [regex[]]$excludes = $this._grouper.BuildExcludes();

    [array]$filtered = $commits;

    if (($this.Options.Selection.Subject)?.Include) {
      $filtered = ($filtered | Where-Object {
          $this._grouper.TestMatchesAny($_.Subject, $includes);
        });    
    }

    if ($filtered) {
      $filtered = ($filtered | Where-Object {
          -not($this._grouper.TestMatchesAny($_.Subject, $excludes));
        });
    }

    if (-not($filtered)) {
      $filtered = @();
      Write-Debug "!!! Release: '$untilLabel'; no commits";
    }
    return $filtered;
  } # filter
} # PoShLog

# === [ GroupBy ] ==============================================================
#

class GroupBy {
  [void] SetDescending([boolean]$value) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (GroupBy.BuildExcludes)');
  }

  [boolean] TestMatchesAny([string]$subject, [regex[]]$expressions) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (GroupBy.TestMatchesAny)');
  } # TestMatchesAny

  [regex[]] BuildExpressions([string[]]$expressions) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (GroupBy.BuildExpressions)');
  } # BuildExpressions

  [regex[]] BuildIncludes() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (GroupBy.BuildIncludes)');
  } # BuildIncludes

  [regex[]] BuildExcludes() {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (GroupBy.BuildExcludes)');
  } # BuildExcludes

  [PSCustomObject[]] Partition(
    [hashtable]$releases, [string[]]$expressions, [PSCustomObject[]]$sortedTags) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (GroupBy.Partition)');
  } # Partition

  [void] Walk([PSCustomObject]$partitionedRelease, [PSCustomObject]$handlers, [PSCustomObject]$custom) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (GroupBy.Walk)');
  } # Walk
}

# === [ GroupByImpl ] ==========================================================
#
class GroupByImpl : GroupBy {
  [PSCustomObject]$Options;
  [boolean]$IsDescending = $true;
  hidden [string[]]$_segments;
  hidden [string]$_leafSegment;
  hidden [string]$_prefix = "partitions:";
  hidden [string]$_uncategorised = "uncategorised";
  hidden [string]$_dirty = "dirty";

  GroupByImpl([PSCustomObject]$options) {
    $this.Options = $options;
    $this._segments = -not([string]::IsNullOrEmpty($this.Options.Output.GroupBy)) ? `
      $this.Options.Output.GroupBy -split '/' : @('ungrouped');

    $this._leafSegment = ($this._segments.Count -gt 0) ? $this._segments[-1] : [string]::Empty;
  } # ctor

  [void] SetDescending([boolean]$value) {
    $this.IsDescending = $value;
  }

  [boolean] TestMatchesAny([string]$subject, [regex[]]$expressions) {
    return ($null -ne $this.GetMatchingRegex($subject, $expressions));
  } # TestMatchesAny

  [regex[]] BuildExpressions ([string[]]$expressions) {
    [regex[]]$result = foreach ($expr in $expressions) {
      [regex]::new($expr);
    }
    return $result;
  } # BuildExpressions

  [regex[]] BuildIncludes() {
    return $this.BuildExpressions(
      ($this.Options.Selection.Subject.Include -is [array]) ? `
        $this.Options.Selection.Subject.Include : @($this.Options.Selection.Subject.Include)
    );
  } # BuildIncludes

  [regex[]] BuildExcludes() {
    return $this.BuildExpressions(
      ($this.Options.Selection.Subject.Exclude -is [array]) ? `
        $this.Options.Selection.Subject.Exclude : @($this.Options.Selection.Subject.Exclude)
    );
  } # BuildExcludes

  [regex] GetMatchingRegex([string]$subject, [regex[]]$expressions) {
    [regex]$matched = $null;
    [int]$current = 0;

    while (-not($matched) -and ($current -lt $expressions.Count)) {
      [regex]$filterRegex = $expressions[$current];
      if ($filterRegex.IsMatch($subject)) {
        $matched = $filterRegex;
      }
      $current++;
    }

    return $matched;
  } # GetMatchingRegex

  # Resolves a path to a leaf. The leaf represents the bucket of commits resolved
  # to from the path.
  #
  # $segmentInfo: [PSTypeName('PoShLog.SegmentInfo')]
  # $partitionedRelease: [PSTypeName('PoShLog.PartitionedRelease')]
  # $handlers: [PSTypeName('PoShLog.Handler')]
  # $custom: [PSTypeName('PoShLog.WalkInfo')]
  #
  [PSCustomObject[]] resolve(
    [PSCustomObject]$segmentInfo,
    [PSCustomObject]$partitionedRelease,
    [PSCustomObject]$handlers,
    [PSCustomObject]$custom) {

    [PSCustomObject]$tagInfo = $partitionedRelease.Tag;
    [hashtable]$partitions = $partitionedRelease.Partitions;

    [PSCustomObject[]]$commits = if ($segmentInfo.Legs.Count -gt 0) {
      $pointer = $partitions;

      [int]$current = 0;
      foreach ($leg in $segmentInfo.Legs) {
        # 0: H3, 1: H4, 2: H5, 3: H6
        #
        if ($current -le 3) {
          [string]$headingNumeral = $("H$($current + 3)");

          $segmentInfo.ActiveSegment = $this._segments[$current];
          $segmentInfo.ActiveLeg = [string]::IsNullOrWhiteSpace($leg) ? $this._uncategorised : $leg;
          $handlers.OnHeading(
            $headingNumeral, $this.Options.Output.Headings.$headingNumeral,
            $segmentInfo, $tagInfo, $handlers.Utils, $custom
          );
          $segmentInfo.ActiveSegment = [string]::Empty;
          $segmentInfo.ActiveLeg = [string]::Empty;
        }

        $pointer = $pointer[$leg];
        $current++;
      }

      if (-not($pointer -is [System.Collections.Generic.List[PSCustomObject]])) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          "GroupByImpl.Resolve: failed to resolve path: '$($segmentInfo.Path)' to commits");
      }
      $pointer;
    }
    else {
      # Uncategorised commits go under a H3
      #
      $partitions[$this._uncategorised];
    }

    return $commits;
  } # resolve

  # Returns: [PSTypeName('PoShLog.SegmentInfo')]
  #
  [PSCustomObject] createSegmentInfo([string]$path) {
    [string[]]$legs = ($path -split '/') | Where-Object { $_ -ne $this._prefix; }

    [int]$legIndex = 0;
    [System.Collections.Generic.List[string]]$decoratedSegments = `
      [System.Collections.Generic.List[string]]::new();
    [hashtable]$segmentToLeg = @{}

    $legs | ForEach-Object {
      [string]$segment = $this._segments[$legIndex];
      $decoratedSegments.Add("$($segment):$_");
      $segmentToLeg[$segment] = $_;

      $legIndex++;
    }
    [string]$decoratedPath = $decoratedSegments -join '/';

    [PSCustomObject]$segmentInfo = [PSCustomObject]@{
      PSTypeName    = 'PoShLog.SegmentInfo';
      Path          = $path;
      Legs          = $legs;
      DecoratedPath = $decoratedPath;
      ActiveSegment = [string]::Empty;
      ActiveLeg     = [string]::Empty;
      IsDirty       = $false;
    }

    $this._segments | ForEach-Object {
      $segmentInfo | Add-Member -NotePropertyName $_ -NotePropertyValue $segmentToLeg[$_];
    }

    return $segmentInfo;
  } # createSegmentInfo

  # A partitioned release contains Partitions and Tag members
  #
  # $partitionedRelease: [PSTypeName('PoShLog.PartitionedRelease')]
  # $handlers: [PSTypeName('PoShLog.Handlers')]
  # $custom: [PSTypeName('PoShLog.WalkInfo')]
  #
  [void] Walk(
    [PSCustomObject]$partitionedRelease,
    [PSCustomObject]$handlers,
    [PSCustomObject]$custom) {

    [PSCustomObject]$tagInfo = $partitionedRelease.Tag;
    [hashtable]$partitions = $partitionedRelease.Partitions;
    [string[]]$paths = $partitionedRelease.Paths;
    [int]$cleanCount = 0;

    # named partitions first
    #
    foreach ($path in $paths) {
      [PSCustomObject]$segmentInfo = $this.createSegmentInfo($path);
      [PSCustomObject[]]$bucket = $this.resolve(
        $segmentInfo, $partitionedRelease, $handlers, $custom
      );

      foreach ($commit in $bucket) {
        # Sort the commits first?
        $handlers.OnCommit(
          $segmentInfo, $commit, $tagInfo, $handlers.Utils, $custom
        );
        $cleanCount++;
      }

      [PSCustomObject]$segmentInfo = [PSCustomObject]@{
        PSTypeName    = 'PoShLog.SegmentInfo';
        #
        Path          = [string]::Empty;
        DecoratedPath = [string]::Empty;
        IsDirty       = $false;
      }
      $handlers.OnEndBucket($segmentInfo, $tagInfo, $handlers.Utils, $custom);
    }

    if (($cleanCount -eq 0) -and $partitions.ContainsKey($this._dirty)) {
      [PSCustomObject]$segmentInfo = [PSCustomObject]@{
        PSTypeName    = 'PoShLog.SegmentInfo';
        #
        Path          = [string]::Empty;
        DecoratedPath = [string]::Empty;
        IsDirty       = $true;
      }
      $handlers.OnHeading(
        'Dirty', $this.Options.Output.Headings.Dirty,
        $segmentInfo, $tagInfo, $handlers.Utils, $custom
      );

      [PSCustomObject]$dirtyCommit = $partitions[$this._dirty][0];
      $handlers.OnCommit(
        $segmentInfo, $dirtyCommit, $tagInfo, $handlers.Utils, $custom
      );

      $handlers.OnEndBucket(
        $segmentInfo, $tagInfo, $handlers.Utils, $custom
      );
    }
  } # Walk

  # To generate the output, we need the releases to be in descending order of
  # the date, but of course, we need to be able to identify each release. Building
  # a hash of release tag to the release collection will not guarantee the order
  # if it's in a hash. So, we need an array. Partition will return an array of
  # PSCustomObjects containing fields: Tag, Partitions and Paths.
  #
  # $sortedTags: [PSTypeName('PoShLog.TagInfo')]
  #
  # Returns: [PSTypeName('PoShLog.PartitionedRelease')][array]
  #
  [PSCustomObject[]] Partition([hashtable]$releases, [PSCustomObject[]]$sortedTags) {

    [regex[]]$expressions = $this.BuildIncludes();
    [System.Collections.Generic.List[PSCustomObject]]$partitioned = `
      [System.Collections.Generic.List[PSCustomObject]]::new();

    [regex]$changeRegex = if ($this.Options.Selection.Subject?.Change -and
      -not([string]::IsNullOrEmpty($this.Options.Selection.Subject.Change))) {
      [regex]::new($this.Options.Selection.Subject.Change);
    }
    else {
      $null;
    }

    [PSCustomObject]$changeTypes = [GeneratorUtils]::CreateIsaLookup(
      'ChangeTypes', $this.Options.Output.Lookup.ChangeTypes
    );
    [PSCustomObject]$scopes = [GeneratorUtils]::CreateIsaLookup(
      'Scopes', $this.Options.Output.Lookup.Scopes
    );
    [PSCustomObject]$types = [GeneratorUtils]::CreateIsaLookup(
      'Types', $this.Options.Output.Lookup.Types
    );

    foreach ($tag in $sortedTags) {
      if ($releases.ContainsKey($tag.Label)) {
        [PSCustomObject]$release = $releases[$tag.Label];
        [PSCustomObject[]]$commits = $this.flatten($release);

        [hashtable]$partitions = @{}
        $pointer = $partitions;

        [System.Collections.Generic.List[string]]$paths = `
          [System.Collections.Generic.List[string]]::new();

        if ($this._segments.Count -eq 0) {
          $partitions[$this._uncategorised] = $commits;
        }
        else {
          Write-Debug "--->>> Partition for release '$($tag.Label)':";

          foreach ($com in $commits) {
            [regex]$partitionRegex = $this.GetMatchingRegex($com.Subject, $expressions);

            if (-not($partitionRegex)) {
              throw [System.Management.Automation.MethodInvocationException]::new(
                "GroupByImpl.Partition: (TAG: '$($tag.Label)') " +
                "internal logic error; commit: '$($com.Subject)' does not match");
            }

            [hashtable]$selectors = @{}
            [System.Text.RegularExpressions.MatchCollection]$mc = $partitionRegex.Matches($com.Subject);
            [System.Text.RegularExpressions.GroupCollection]$groups = $mc[0].Groups;

            [PoShLogProfile]::GetSegments($this._segments) | ForEach-Object {
              if ($groups.ContainsKey($_) ) {
                # IMPORTANT: a value must be allowed to be left to be the empty string. Do
                # NOT attempt to assign to some default like 'uncategorised' otherwise
                # this will break condition statements.
                #
                [string]$capture = $groups[$_].Success ? $groups[$_].Value : [string]::Empty;
                $selectors[$_] = if ($_ -eq 'break') {
                  # Exception override for break, the '!' is not a very useful
                  # value, so translate to something more explicit.
                  #
                  [PoShLogProfile]::BreakingValue($capture)
                }
                elseif ($_ -eq 'scope') {
                  $scopes.Isa.ContainsKey($capture) ? $scopes.Isa[$capture] : $capture;
                }
                elseif ($_ -eq 'type') {
                  $types.Isa.ContainsKey($capture) ? $types.Isa[$capture] : $capture;
                }
                elseif ($_ -eq 'change') {
                  $changeTypes.Isa.ContainsKey($capture) ? $changeTypes.Isa[$capture] : $capture;
                }
                else {
                  $capture;
                }
              }
            }

            if (-not($groups.ContainsKey('change')) -and ($null -ne $changeRegex)) {
              if ($groups.ContainsKey('body') -and $groups['body'].Success) {
                [string]$body = $groups['body'].Value;

                if ($changeRegex.IsMatch($body)) {
                  [string]$change = $changeRegex.Matches($body)[0].Value.Trim().ToLower();
                  $selectors['change'] = $changeTypes.Isa.ContainsKey($change) ? `
                    $changeTypes.Isa[$change] : [string]::Empty
                }
              }
            }

            $com.Info = [PSCustomObject]@{
              PSTypeName = 'PoShLog.CommitInfo';
              Selectors  = $selectors;
              IsBreaking = $($groups.ContainsKey('break') -and $groups['break'].Success);
              Groups     = $groups;
            }

            # $pointer can point to either a hashtable or a List. If it currently points
            # to a hashtable, then we're only part way through the groupBy path. If pointer
            # points to a List, then we have reached the end of the path, the leaf. When
            # we reach the leaf, we have found where we need to add the commit to. So
            # we end up with multiple layers of hashes, where the leaf elements of the hashes
            # is an array of commits (bucket). All commits in the same bucket, possess
            # the same set of characteristics defined by the groupBy path.
            #
            [string]$path = $this._prefix;
            foreach ($segment in $this._segments) {
              # Set the selector from the commit fields
              #
              [string]$selector = if ($selectors.ContainsKey($segment)) {
                $selectors[$segment];
              }
              else {
                $this._uncategorised;
              }
              $path += "/$selector";

              if (-not($pointer.ContainsKey($selector))) {
                $pointer[$selector] = ($segment -eq $this._leafSegment) ? `
                  [System.Collections.Generic.List[PSCustomObject]]::new() : @{};
              }
              $pointer = $pointer[$selector];
            } # foreach ($segment in $this._segments)

            if ($pointer -is [System.Collections.Generic.List[PSCustomObject]]) {
              Write-Debug " ~ '$path' Adding commit '$($com.Subject)'";
              $paths.Add($path);
              $pointer.Add($com);
            }
            else {
              throw "something went wrong, reached leaf, but is not a list '$($pointer)'"
            }
            $pointer = $partitions;
          } # foreach ($com in $commits)

          if (($commits.Count -eq 0) -and ($release)?.Dirty) {
            $partitions[$this._dirty] = $release.Dirty;
          }
        }

        # Since the commits have been flattened, it no longer reflects the buckets. This means
        # that when $paths is added to, commits may be multiple counted, because the same path
        # within a release could be added more than once.
        #
        $paths = $($paths | Sort-Object | Get-Unique);

        $partitionItem = [PSCustomObject]@{
          PSTypeName = 'PoShLog.PartitionedRelease';
          Tag        = $tag;
          Partitions = $partitions;
          Paths      = $paths;
        }
        $partitioned.Add($partitionItem);

        if (($commits.Count -eq 0) -and ($release)?.Dirty) {
          Write-Debug "!!! Found '$($release.Dirty.Count)' DIRTY commits for release: '$($release.Label)'"
        }
      } # if ($releases.ContainsKey($tag.Label))
    } # foreach ($tag in $sortedTags)

    return $partitioned;
  } # Partition

  # $squashedRelease: [PSTypeName('PoShLog.SquashedRelease')]
  #
  # Returns: [PSTypeName('PoShLog.CommitInfo')][array]
  #
  [PSCustomObject[]] flatten([PSCustomObject]$squashedRelease) {

    [boolean]$selectLast = ($this.Options.Selection)?.Last -and $this.Options.Selection.Last;

    [System.Collections.Generic.List[PSCustomObject]]$squashed = `
      [System.Collections.Generic.List[PSCustomObject]]::new();

    if (($squashedRelease)?.Squashed -and $squashedRelease.Squashed.PSBase.Count -gt 0) {
      [string[]]$issues = $squashedRelease.Squashed.PSBase.Keys;

      foreach ($issue in $issues) {
        $item = $squashedRelease.Squashed[$issue];

        if ($item -is [PSCustomObject]) {
          $squashed.Add($item);
        }
        elseif ($item -is [System.Collections.Generic.List[PSCustomObject]]) {
          $squashed.Add($selectLast ? $item[-1] : $item[0]);
        }
        else {
          throw [System.Management.Automation.MethodInvocationException]::new(
            $(
              "GroupByImpl.flatten: found bad squashed item of type " +
              "$($item.GetType()) for release: '$($squashedRelease.Label)'"
            )
          );
        }
      }
    }

    [PSCustomObject[]]$others = (($squashedRelease)?.Commits -and $squashedRelease.Commits.Count -gt 0) `
      ? $squashedRelease.Commits : @();

    [PSCustomObject[]]$flattened = $squashed + $others;

    return $flattened;
  } # flatten

  # The resultant array is designed only to be iterated, we don't need direct access to
  # each release
  #
  [PSCustomObject[]] SortReleasesByDate([hashtable]$releases, [PSCustomObject[]]$sortedTags) {

    [PSCustomObject[]]$sorted = foreach ($tagInfo in $sortedTags) {
      if ($releases.ContainsKey($tagInfo.Label)) {
        $releases[$tagInfo.Label]
      }
    }

    return $sorted;
  } # SortReleasesByDate

  [int[]] CountCommits([PSCustomObject[]]$sortedReleases) {
    [int]$squashed = -1;
    [int]$all = -1;

    foreach ($release in $sortedReleases) {
      if (($release)?.Commits) {
        $all += $release.Commits;
        $squashed += $release.Commits;
      }

      if (($release)?.Squashed) {
        $all += $release.Squashed.PSBase.Count;
        $squashed++;
      }
    }
    return $squashed, $all;
  } # CountCommits
} # GroupByImpl

# === [ PoShLogGenerator ] ===================================================
#
class PoShLogGenerator {
  [PSCustomObject]$Options;
  [SourceControl]$_sourceControl;
  [GroupBy]$_grouper;
  [string]$_baseUrl;

  PoShLogGenerator([PSCustomObject]$options, [SourceControl]$sourceControl, [GroupBy]$grouper) {
    $this.Options = $options;
    $this._sourceControl = $sourceControl;
    $this._grouper = $grouper;
    $this._baseUrl = $this._sourceControl.ReadRemoteUrl();
  }

  [void] SetDescending([boolean]$value) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (PoShLogGenerator.SetDescending)');
  }

  [string] Generate([PSCustomObject[]]$releases, [object]$template, [PSCustomObject[]]$tagsInRange) {
    throw [System.Management.Automation.MethodInvocationException]::new(
      'Abstract method not implemented (PoShLogGenerator.Generate)');
  }
}

# === [ MarkdownPoShLogGenerator ] ===========================================
#
class MarkdownPoShLogGenerator : PoShLogGenerator {
  [GeneratorUtils]$_utils;
  [boolean]$IsDescending = $true;

  MarkdownPoShLogGenerator(
    [PSCustomObject]$options, [SourceControl]$sourceControl, [GroupBy]$grouper
  ): base ($options, $sourceControl, $grouper) {

    [PSCustomObject]$generatorInfo = [PSCustomObject]@{
      PSTypeName = 'PoShLog.GeneratorInfo';
      #
      BaseUrl    = $this._baseUrl;
    }
    $this._utils = [GeneratorUtils]::new($options, $generatorInfo);
  } #ctor

  [void] SetDescending([boolean]$value) {
    $this.IsDescending = $value;
  }

  [string] Generate([PSCustomObject[]]$releases, [object]$template, [PSCustomObject[]]$tagsInRange) {
    [LineAppender]$appender = [LineAppender]::new();

    [scriptblock]$OnCommit = {
      param(
        [System.Management.Automation.PSTypeName('PoShLog.SegmentInfo')]$segmentInfo,
        [System.Management.Automation.PSTypeName('PoShLog.CommitInfo')]$commit,
        [System.Management.Automation.PSTypeName('PoShLog.TagInfo')]$tagInfo,
        [GeneratorUtils]$utils,
        [System.Management.Automation.PSTypeName('PoShLog.WalkInfo')]$custom
      )
      [PSCustomObject]$output = $custom.Options.Output;

      [string]$commitStmt = $segmentInfo.IsDirty ? $output.Statements.DirtyCommit: $output.Statements.Commit;
      [hashtable]$commitVariables = $utils.GetCommitVariables($commit, $tagInfo);
      [string]$commitLine = $utils.Evaluate($commitStmt, $commit, $commitVariables);
      $commitLine = $utils.SpacesRegex.Replace($commitLine, ' ');

      $custom.Appender.AppendLine($commitLine);
    } # OnCommit

    [scriptblock]$OnEndBucket = {
      param(
        [System.Management.Automation.PSTypeName('PoShLog.SegmentInfo')]$segmentInfo,
        [System.Management.Automation.PSTypeName('PoShLog.TagInfo')]$tagInfo,
        [GeneratorUtils]$utils,
        [System.Management.Automation.PSTypeName('PoShLog.WalkInfo')]$custom
      )
      [PSCustomObject]$output = $custom.Options.Output;

      if (${output}?.Literals.BucketEnd -and -not([string]::IsNullOrEmpty($output.Literals.BucketEnd))) {
        $custom.Appender.AppendLine([string]::Empty);
        $custom.Appender.AppendLine($output.Literals.BucketEnd);
      }
    } # OnEndBucket

    [scriptblock]$OnHeading = {
      param(
        [string]$headingType,
        [string]$headingStmt,
        [System.Management.Automation.PSTypeName('PoShLog.SegmentInfo')]$segmentInfo,
        [System.Management.Automation.PSTypeName('PoShLog.WalkInfo')]$tagInfo,
        [GeneratorUtils]$utils,
        [System.Management.Automation.PSTypeName('PoShLog.WalkInfo')]$custom
      )
      [string]$prefix = [GeneratorUtils]::HeadingPrefix($headingType);
      if (-not($headingStmt.StartsWith($prefix))) {
        $headingStmt = $prefix + $headingStmt;
      }
      [hashtable]$headingVariables = $utils.GetHeadingVariables($segmentInfo, $tagInfo);

      [PSCustomObject]$commit = $null;
      [string]$headingLine = $utils.Evaluate($headingStmt, $commit, $headingVariables).Trim();
      $headingLine = $utils.SpacesRegex.Replace($headingLine, ' ');

      Write-Debug "--> Heading('$headingType'): Eval: '$headingLine', Scope: '$($headingVariables['scope'])'";

      $custom.Appender.AppendLine([string]::Empty);
      $custom.Appender.AppendLine($headingLine);
      $custom.Appender.AppendLine([string]::Empty);
    } # OnHeading

    [scriptblock]$OnSection = {
      param(
        [string]$sectionName,
        [string]$titleStmt,
        [string[]]$content,
        [System.Management.Automation.PSTypeName('PoShLog.SegmentInfo')]$segmentInfo,
        [System.Management.Automation.PSTypeName('PoShLog.WalkInfo')]$tagInfo,
        [GeneratorUtils]$utils,
        [System.Management.Automation.PSTypeName('PoShLog.WalkInfo')]$custom
      )
      [PSCustomObject]$commit = $null;
      [hashtable]$headingVariables = $utils.GetHeadingVariables($segmentInfo, $tagInfo);
      [string]$title = $utils.Evaluate($titleStmt, $commit, $headingVariables).Trim();
      Write-Debug "--> Section('$sectionName'): Title: $title, Scope: '$($headingVariables['scope'])'";
      $custom.Appender.AppendLine($title);

      foreach ($stmt in $content) {
        [string]$entry = [string]$title = $utils.Evaluate($stmt, $commit, $headingVariables).Trim();
        $custom.Appender.AppendLine($entry);
      }
      $custom.Appender.AppendLine([string]::Empty);
    }

    [PSCustomObject]$handlers = [PSCustomObject]@{
      PSTypeName = 'PoShLog.Handlers';
      #
      Utils      = $this._utils;
    }

    $handlers | Add-Member -MemberType ScriptMethod -Name 'OnHeading' -Value $($OnHeading);
    $handlers | Add-Member -MemberType ScriptMethod -Name 'OnCommit' -Value $($OnCommit);
    $handlers | Add-Member -MemberType ScriptMethod -Name 'OnEndBucket' -Value $($OnEndBucket);
    $handlers | Add-Member -MemberType ScriptMethod -Name 'OnSection' -Value $($OnSection);

    [string]$releaseStmt = $this.Options.Output.Headings.H2;

    if (-not($releaseStmt.StartsWith('## '))) {
      $releaseStmt = '## ' + $releaseStmt;
    }

    [PSCustomObject]$customWalkInfo = [PSCustomObject]@{
      PSTypeName = 'PoShLog.WalkInfo';
      #
      Appender   = $appender;
      Options    = $this.Options;
    }
    $nullSegmentInfo = $null;

    foreach ($release in $releases) {
      $handlers.OnHeading(
        'H2', $this.Options.Output.Headings.H2,
        $nullSegmentInfo, $release.Tag, $handlers.Utils, $customWalkInfo
      );

      if (-not([string]::IsNullOrEmpty($this.Options.Output.Sections.Release.Highlights))) {
        $handlers.OnSection(
          'Highlights',
          $this.Options.Output.Sections.Release.Highlights,
          $this.Options.Output.Sections.Release.HighlightContent,
          $nullSegmentInfo,
          $release.Tag,
          $this._utils, $customWalkInfo);
      }

      $this._grouper.Walk($release, $handlers, $customWalkInfo);
    }

    [PSCustomObject[]]$linkTags = $this._utils.GetLinkTags(
      $tagsInRange, $this._sourceControl.AllTagsWithoutHead
    );
    [string]$linksContent = $this.CreateComparisonLinks($linkTags);
    [string]$warningsContent = $this.CreateDisabledWarnings();
    [string]$schemaVersionContent = $this.CreateSchemaVersion();

    [array]$constituents = @(
      @{ Name = 'links'; Content = $linksContent },
      @{ Name = 'schema-version'; Content = $schemaVersionContent },
      @{ Name = 'warnings'; Content = $warningsContent },
      @{ Name = 'content'; Content = $appender.ToString() }
    )
    [string]$markdown = $template;
    foreach ($const in $constituents) {
      $markdown = $markdown.replace(
        $([PoShLogProfile]::MD_CONTENT_FORMAT -f $const.Name), $const.Content
      );
    }

    return $markdown;
  } # Generate

  [string] CreateComparisonLinks([PSCustomObject[]]$tagsInRange) {
    [string]$baseUrl = $this._sourceControl.ReadRemoteUrl();
    [System.Text.StringBuilder]$builder = [System.Text.StringBuilder]::new();

    if ($tagsInRange.Count -gt 1) {
      [PSCustomObject]$first, [PSCustomObject[]]$others = $tagsInRange;

      foreach ($second in $others) {
        [string]$name = [GeneratorUtils]::TagDisplayName($first.Label);
        $builder.AppendLine(
          "[$($name)]: $($baseUrl)/compare/$($second.Label)...$($first.Label)"
        );

        $first = $second;
      }
    }

    return $builder.ToString();
  }

  [string] CreateDisabledWarnings() {

    [hashtable]$disabled = $this.Options.Output?.Warnings?.Disable;

    [string]$content = if (($null -ne $disabled) -and $disabled.PSBase.Count -gt 0) {
      [System.Text.StringBuilder]$builder = [System.Text.StringBuilder]::new();
      [string[]]$warningCodes = $disabled.Keys;
      [string]$markdownFormat = "<!-- MarkDownLint-disable {0} -->";

      if ($warningCodes.Count -eq 1) {
        [void]$builder.AppendLine(
          $($markdownFormat -f $warningCodes[0])
        );
      }
      else {
        [string]$last = $warningCodes[-1];
        [string[]]$others = $warningCodes[0..$($warningCodes.Count - 2)];

        foreach ($code in $others) {
          [void]$builder.AppendLine(
            $($markdownFormat -f $code)
          );
        }
        [void]$builder.Append(
          $($markdownFormat -f $last)
        );
      }
      
      $builder.ToString();
    }
    else {
      [string]::Empty;
    }
    return $content;
  }

  [string] CreateSchemaVersion() {
    return $("<!-- Elizium.Loopz PoShLog options json schema version '{0}' -->" -f [PoShLogProfile]::SCHEMA_VERSION);
  }
} # MarkdownPoShLogGenerator

# === [ PoShLogProfile ] ======================================================
#
# conditional -> ?{var;name}

class PoShLogProfile {
  static [string]$PREFIXES = '?!&^*+';
  static [regex]$FieldRegex = [regex]::new(
    "(?<prefix>[$([PoShLogProfile]::PREFIXES)])\{(?:(?<var>[\w\-]+);)?(?<symbol>[\w\-]+)(?:;(?<else>[\w\-]+))?\}"
  );
  static [string] Snippet([char]$prefix, [string]$symbol) {
    return "$($prefix){$($symbol)}";
  }

  static [string[]] GetSegments([string[]]$segments) {
    [string[]]$result = @('break', 'change', 'scope', 'type');
    if ($segments.Count -eq 1 -and $segments[0] -eq 'ungrouped') {
      $result += 'ungrouped';
    }

    return $result;
  }
  static [string] BreakingValue ([string]$value) {
    return [string]::IsNullOrEmpty($value) ? 'non-breaking' : 'breaking';
  }
  static [string]$LOOKUP_UNKNOWN = '?';

  static [string] StatementPlaceholder() {
    return [PoShLogProfile]::Snippet('*', '$');
  }
  static [string]$MD_CONTENT_FORMAT = "[[{0}]]";
  static [string]$TEMPLATE_FILENAME = "TEMPLATE.md";

  static [string]$OPTIONS_SCHEMA_FILENAME = 'posh-log.options.schema.json';
  static [string]$SCHEMA_VERSION = '1.0.0';

  static [string]$DIRECTORY = '.posh-log';
} # PoShLogProfile

# === [ GeneratorUtils ] =======================================================
#
class GeneratorUtils {
  [PSCustomObject]$Options;
  [PSCustomObject]$Output;
  [PSCustomObject]$GeneratorInfo;
  [regex]$SpacesRegex = [regex]::new('\s{2,}');

  static [hashtable]$_headings = @{
    'H2'    = '## ';
    'H3'    = '### ';
    'H4'    = '#### ';
    'H5'    = '##### ';
    'H6'    = '###### ';
    'Dirty' = '### ';
  };

  static [hashtable]$_lookups = @{
    '_A' = [PSCustomObject]@{
      PSTypeName = 'PoShLog.GeneratorUtils.Lookup';
      Instance   = 'Authors';
      Variable   = 'author';
    };

    '_B' = [PSCustomObject]@{
      PSTypeName = 'PoShLog.GeneratorUtils.Lookup';
      Instance   = 'BreakingStatus';
      Variable   = 'break';
    };

    '_C' = [PSCustomObject]@{
      PSTypeName = 'PoShLog.GeneratorUtils.Lookup';
      Instance   = 'ChangeTypes';
      Variable   = 'change';
    };

    '_S' = [PSCustomObject]@{
      PSTypeName = 'PoShLog.GeneratorUtils.Lookup';
      Instance   = 'Scopes';
      Variable   = 'scope';
    };

    '_T' = [PSCustomObject]@{
      PSTypeName = 'PoShLog.GeneratorUtils.Lookup';
      Instance   = 'Types';
      Variable   = 'type';
    };
  }

  GeneratorUtils([PSCustomObject]$options, [PSCustomObject]$generatorInfo) {
    $this.Options = $options;
    $this.Output = $options.Output;
    $this.GeneratorInfo = $generatorInfo;
  }

  static [string] ConditionalSnippet([string]$value) {
    return [PoShLogProfile]::Snippet('?', $value)
  }

  static [string] ConditionalVariableSnippet([string]$var, [string]$value, [string]$else) {
    return [string]::IsNullOrEmpty($else) ? `
      [PoShLogProfile]::Snippet('?', "$($var);$($value)") : `
      [PoShLogProfile]::Snippet('?', "$($var);$($value);$($else)");
  }

  static [string] LiteralSnippet([string]$value) {
    return [PoShLogProfile]::Snippet('!', $value)
  }

  static [string] LookupSnippet([string]$value) {
    return [PoShLogProfile]::Snippet('&', $value)
  }

  static [string] NamedGroupRefSnippet([string]$value) {
    return [PoShLogProfile]::Snippet('^', $value)
  }

  static [string] StatementSnippet([string]$value) {
    return [PoShLogProfile]::Snippet('*', $value)
  }

  static [string] VariableSnippet([string]$value) {
    return [PoShLogProfile]::Snippet('+', $value)
  }

  static [string] HeadingPrefix([string]$headingType) {
    return [GeneratorUtils]::_headings.ContainsKey($headingType) ? `
      [GeneratorUtils]::_headings[$headingType] : [string]::Empty;
  }

  static [string] AnySnippetExpression($value) {
    [string]$escaped = [regex]::Escape("{$value}");
    return "(?:[$([PoShLogProfile]::PREFIXES)])$($escaped)";
  }

  static [string] TagDisplayName([string]$label) {
    return $($label -eq 'HEAD') ? 'Unreleased' : $label;
  }

  static [PSCustomObject] CreateIsaLookup([string]$name, [hashtable]$lookup) {
    [PSCustomObject]$result = @{
      Isa   = @{};
      Value = @{};
    }
    [regex]$isaRegex = [regex]::new('^isa\:(?<parent>[^:]+)$');

    $lookup.Keys | ForEach-Object {
      if ($isaRegex.IsMatch($lookup[$_])) {
        [System.Text.RegularExpressions.Match]$m = $isaRegex.Matches($lookup[$_])?[0];
        [string]$parent = ${m}?.Groups['parent'].Success ? `
          $m.Groups['parent'].Value.Trim() : [string]::Empty;

        if ($_ -eq $parent) {
          throw [System.Management.Automation.MethodInvocationException]::new(
            $(
              "GeneratorUtils.CreateIsaLookup: found invalid isa entry: '$_'" +
              ", refers to itself in '$name'."
            )
          );
        }

        if (-not($lookup.ContainsKey($parent))) {
          throw [System.Management.Automation.MethodInvocationException]::new(
            $(
              "GeneratorUtils.CreateIsaLookup: found invalid isa entry: '$_'" +
              ", '$parent' does not exist in '$name'."
            )
          );
        }

        if (-not([string]::IsNullOrEmpty($parent))) {
          $result.Isa[$_] = $parent;
          $result.Value[$_] = $lookup[$parent];
        }
      }
      else {
        $result.Isa[$_] = $_;
        $result.Value[$_] = $lookup[$_];
      }
    }

    return $result;
  }

  [string] AvatarImg([string]$username) {
    [string]$hostUrl = ($this.Options)?.SourceControl.HostUrl;
    [string]$size = ($this.Options)?.SourceControl.AvatarSize;
    [string]$imgElement = $(
      "<img title='$($username)' src='$($hostUrl)$($username).png?size=$($size)'>"
    );

    return $imgElement;
  }

  [string] CommitIdLink([PSCustomObject]$commit) {
    [string]$baseUrl = $this.GeneratorInfo.BaseUrl;
    [string]$fullHash = $commit.FullHash;

    [string]$link = $(
      "[$($commit.CommitId)]($($baseUrl)/commit/$fullHash)"
    );

    return $link;
  }

  [string] IssueLink([string]$issue) {
    [string]$baseUrl = $this.GeneratorInfo.BaseUrl;

    [string]$link = -not([string]::IsNullOrEmpty($baseUrl)) ? $(
      "[#$($issue)]($($baseUrl)/issues/$($issue))";
    ) : [string]::Empty;

    return $link;
  }

  [string] GetVariable([string]$name, [PSCustomObject]$commit, [hashtable]$variables) {
    [string]$result = if ($variables.ContainsKey($name)) {
      $variables[$name];
    }
    elseif (($null -ne $commit) -and ($null -ne $commit.Info) -and $commit.Info.Groups.ContainsKey($name)) {
      $commit.Info.Groups[$name].Value.Trim();
    }
    else {
      [string]::Empty;
    }

    return $result;
  }

  [string] GetStatement([string]$name, [PSCustomObject]$options, [hashtable]$variables) {

    [string]$result = if ($name.EndsWith('Stmt')) {
      $name = $name -replace 'Stmt';

      if (-not([string]::IsNullOrEmpty($options.Output.Statements?.$name))) {
        $options.Output.Statements.$name;
      }
      else {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "GeneratorUtils.get-statement: error in options file" +
            ", '$($name)' is not defined Statement"
          )
        );
      }
    }
    elseif (-not([string]::IsNullOrEmpty($options.Output.Literals?.$name))) {
      $options.Output.Literals.$name;
    }
    else {
      throw [System.Management.Automation.MethodInvocationException]::new(
        $(
          "GeneratorUtils.get-statement: error in options file" +
          ", '$($name)' is not defined Literal"
        )
      );
    }

    return $result;
  }

  # generic conditional statement with else
  #
  [string] IfStatement(
    [string]$variable, [string]$stmt, [PSCustomObject]$commit,
    [hashtable]$variables, [string]$else, [string[]]$trail) {

    [string]$variableValue = $this.GetVariable($variable, $commit, $variables);
    [boolean]$affirmative = (-not([string]::IsNullOrEmpty($variableValue))) -and ($variableValue -ne 'false');

    [string]$result = if ($affirmative) {
      [string]$stmtValue = $this.GetStatement($stmt, $this.Options, $variables);
      $this.evaluateStmt($stmtValue, $commit, $variables, $trail);
    }
    else {
      if (-not([string]::IsNullOrEmpty($else))) {
        [string]$elseValue = $this.GetStatement($else, $this.Options, $variables);
        $this.Evaluate($elseValue, $commit, $variables);
      }
      else {
        [string]::Empty;
      }
    }

    return $result
  }

  # $segmentInfo can be null
  #
  [hashtable] GetHeadingVariables([PSCustomObject]$segmentInfo, [PSCustomObject]$tagInfo) {

    [string]$displayTag = [GeneratorUtils]::TagDisplayName($tagInfo.Label);
    [hashtable]$headingVariables = @{
      'date'        = $tagInfo.Date.ToString($this.Output.Literals.DateFormat);
      'display-tag' = $displayTag;
      'tag'         = $tagInfo.Label;
      'link'        = "[$displayTag]";
    }

    if ($null -ne $segmentInfo) {
      $headingVariables['active-leg'] = $segmentInfo.ActiveLeg;
      $headingVariables['active-segment'] = $segmentInfo.ActiveSegment;

      [PoShLogProfile]::GetSegments($this._segments) | ForEach-Object {
        if (${segmentInfo}?.$_) {
          $headingVariables[$_] = $segmentInfo.$_;
        }
      }
    }

    return $headingVariables;
  }

  [hashtable] GetCommitVariables([PSCustomObject]$commit, [PSCustomObject]$tagInfo) {

    [hashtable]$commitVariables = @{
      'author'        = $commit.Author;
      'avatar-img'    = $this.AvatarImg($commit.Author);
      'date'          = $commit.Date.ToString($this.Output.Literals.DateFormat);
      'display-tag'   = [GeneratorUtils]::TagDisplayName($tagInfo.Label);
      'is-breaking'   = $commit.Info.IsBreaking;
      'is-squashed'   = $commit.Info.IsSquashed;
      'subject'       = $commit.Subject;
      'tag'           = $tagInfo.Label;
      'commitid'      = $commit.CommitId;
      'commitid-link' = $this.CommitIdLink($commit);
    }

    if (${commit}.Info -and $commit.Info.Groups['issue'] -and
      $commit.Info.Groups['issue'].Success) {
      [string]$issue = $commit.Info.Groups['issue'].Value;

      $commitVariables['issue'] = $issue;
      $commitVariables['issue-link'] = $this.IssueLink($issue);
    }

    if (${commit}?.Info) {
      [PoShLogProfile]::GetSegments($this._segments) | ForEach-Object {
        if ($commit.Info.Selectors.ContainsKey($_)) {
          $commitVariables[$_] = $commit.Info.Selectors[$_];
        }
      }
    }

    return $commitVariables;
  } # GetCommitVariables

  # |<-- most recent oldest -->|
  # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
  # HEAD, 3.0.2, 3.0.1, 3.0.0, 2.0.0, 1.2.0, 1.1.1, 1.1.0, 1.0.1, 1.0.0
  # |<-- in range -->| (assuming in range = 2.0.0..3.0.1)
  # last in range index = 4, so we add item with index 5
  #
  # If tags in range includes the oldest tag, then just return tagsInRange
  # otherwise we need to get 1 extra older tag from allTags and append
  # that to end of tagsInRange.
  #
  [PSCustomObject[]] GetLinkTags([PSCustomObject[]]$tagsInRange, [PSCustomObject[]]$allTags) {

    [PSCustomObject]$lastTagInRange = $tagsInRange[-1];
    [PSCustomObject]$oldestTag = $allTags[-1];

    [PSCustomObject[]]$linkTags = if ($lastTagInRange.Label -eq $oldestTag.Label) {
      @($tagsInRange);
    }
    else {
      [System.Predicate[PSCustomObject]]$predicate = [System.Predicate[PSCustomObject]] {
        param(
          [PSCustomObject]$item
        )
        $item.Label -eq $lastTagInRange.Label;
      }
      [int]$indexLastInRange = [Array]::FindIndex($allTags, $predicate);
      @($tagsInRange) + $allTags[$indexLastInRange + 1];
    }

    return $linkTags;
  }

  [string] Evaluate(
    [string]$source,
    [PSCustomObject]$commit,
    [hashtable]$variables) {

    [string[]]$trail = @();
    [string]$statement = $this.evaluateStmt($source, $commit, $variables, $trail);
    return $this.ClearUnresolvedFields($statement);
  } # Evaluate

  [string] evaluateStmt([string]$source,
    [PSCustomObject]$commit,
    [hashtable]$variables,
    [string[]]$trail) {

    [string]$result = if ([PoShLogProfile]::FieldRegex.IsMatch($source)) {
      [System.Text.RegularExpressions.MatchCollection]$mc = [PoShLogProfile]::FieldRegex.Matches($source);

      [string]$evolve = $source;
      foreach ($m in $mc) {
        [System.Text.RegularExpressions.GroupCollection]$groups = $m.Groups;

        if ($groups['prefix'].Success -and $groups['symbol'].Success) {
          [string]$prefix = $groups['prefix'].Value;
          [string]$symbol = $groups['symbol'].Value;

          if ($trail -notContains $symbol) {

            [string]$target, [string]$with = switch ($prefix) {
              '*' {
                [string]$snippet = $groups[0];
                $trail += $symbol;

                if ($evolve.Contains($snippet)) {
                  [string]$property = $symbol -replace 'Stmt';

                  if ($null -eq $this.Output.Statements?.$property) {
                    throw [System.Management.Automation.MethodInvocationException]::new(
                      "GeneratorUtils.evaluateStmt(bad options config): " +
                      "'$($symbol)' is not a defined Statement");
                  }

                  [string]$statement = $this.Output.Statements.$property;
                  [string]$replacement = $this.evaluateStmt(
                    $statement, $commit, $variables, $trail
                  );

                  $snippet, $replacement
                }
                break;
              }

              '?' {
                [string]$variable = $groups['var'].Value;
                [string]$else = ($groups.ContainsKey('else')) ? $groups['else'].Value : [string]::Empty;
                [string]$snippet = $groups[0];
                $trail += $symbol;

                if ($evolve.Contains($snippet)) {
                  [string]$replacement = $this.IfStatement(
                    $variable, $symbol, $commit, $variables, $else, $trail
                  );

                  # we need to recurse here just in-case the expansion has resulted in
                  # unresolved references.
                  #
                  if (-not([string]::IsNullOrEmpty($replacement))) {
                    $replacement = $this.evaluateStmt(
                      $replacement, $commit, $variables, $trail
                    );
                  }

                  $snippet, $replacement
                }
                break;
              }

              '!' {
                [string]$snippet = $groups[0];

                if ($evolve.Contains($snippet)) {
                  if ($null -eq ($this.Output.Literals)?.$symbol) {
                    throw [System.Management.Automation.MethodInvocationException]::new(
                      "GeneratorUtils.evaluateStmt(bad options config): " +
                      "'$($symbol)' is not a defined Literal"
                    );
                  }
                  [string]$replacement = $this.Output.Literals.$symbol;
                  $snippet, $replacement
                }
                break;
              }

              '&' {
                [string]$snippet = $groups[0];

                if ($evolve.Contains($snippet)) {
                  if (-not([GeneratorUtils]::_lookups.ContainsKey($symbol))) {
                    throw [System.Management.Automation.MethodInvocationException]::new(
                      $(
                        "GeneratorUtils.Evaluate(bad options config): " +
                        "Lookup '$symbol' not found"
                      )
                    );
                  }

                  [string]$instance = [GeneratorUtils]::_lookups[$symbol].Instance;
                  [string]$variable = [GeneratorUtils]::_lookups[$symbol].Variable;
                  [string]$seek = $variables[$variable];

                  [string]$replacement = (
                    $this.Output.Lookup.$instance.ContainsKey($seek)) ? `
                    $this.Output.Lookup.$instance[$seek] : $($this.Output.Lookup.$instance['?'] ?? [string]::Empty);

                  $snippet, $replacement
                }
                break;
              }

              '^' {
                [string]$snippet = $groups[0];

                if ($evolve.Contains($snippet)) {
                  [string]$replacement = if (($commit)?.Info.Groups -and `
                      $commit.Info.Groups[$symbol].Success) {
                    $commit.Info.Groups[$symbol].Value.Trim();
                  }
                  else {
                    [string]::Empty;
                  }

                  $snippet, $replacement
                }
                break;
              }

              '+' {
                [string]$snippet = $groups[0];

                if ($evolve.Contains($snippet)) {
                  [string]$replacement = ($variables.ContainsKey($symbol)) `
                    ? $variables[$symbol]: [string]::Empty;

                  $snippet, $replacement
                }
                break;
              }
            }
            $evolve = $evolve.Replace($target, $with);
          }
          else {
            throw [System.Management.Automation.MethodInvocationException]::new(
              $(
                "GeneratorUtils.Evaluate(bad options config): " +
                "statement: '$source' contains circular reference: '$symbol'"
              )
            );
          }
        }
        else {
          throw [System.Management.Automation.MethodInvocationException]::new(
            $(
              "GeneratorUtils.Evaluate(prefix/symbol): " +
              "statement: '$source' contains failed group references"
            )
          );
        }
      }
      $evolve;
    }
    else {
      $source;
    }

    return $result;
  } # evaluateStmt

  [string] ClearUnresolvedFields($value) {
    return [PoShLogProfile]::FieldRegex.Replace($value, '');
  }
} # GeneratorUtils

# === [ PoShLogOptionsManager ] ==============================================
#
class PoShLogOptionsManager {
  [PSCustomObject]$OptionsInfo;
  [boolean]$Found;
  [ProxyGit]$Proxy;

  PoShLogOptionsManager([ProxyGit]$proxy, [PSCustomObject]$optionsInfo) {

    $this.Proxy = $proxy;
    $this.OptionsInfo = $optionsInfo;
  }

  [void] Init() {
    [void]$this.IsValidGroupBy($this.OptionsInfo.GroupBy);
  }

  [string] ReadRootPath() {
    [string]$root = if (($this.OptionsInfo)?.Root -and `
        -not([string]::IsNullOrEmpty($this.OptionsInfo.Root))) {
      $this.OptionsInfo.Root
    }
    else {
      $this.Proxy.Root();
    }
    return $root;
  }

  [string] FileName([string]$name, [boolean]$ifEmoji) {
    return $ifEmoji ? $($name + '-emoji' + $this.OptionsInfo.Base) : $($name + $this.OptionsInfo.Base);
  }

  [string] FullPath([string]$name, [boolean]$ifEmoji) {
    [string]$directoryPath = $this.DirectoryPath()
    [string]$fileName = $this.FileName($name, $ifEmoji);
    [string]$withExtension = $fileName + '.json';
    [string]$fullPath = Join-Path -Path $directoryPath -ChildPath $withExtension;

    return $fullPath;
  }

  [string] DirectoryPath([string]$fileName) {
    [string]$directoryPath = $this.DirectoryPath();
    return Join-Path -Path $directoryPath $fileName; 
  }

  [string] DirectoryPath() {
    [string]$root = $this.ReadRootPath();
    [string]$directoryPath = Join-Path -Path $root -ChildPath $this.OptionsInfo.DirectoryName;
    return $directoryPath;
  }

  [string] EnsureRepoDirectoryPath() {
    [string]$directoryPath = $this.DirectoryPath();

    if (-not(Test-Path -LiteralPath $directoryPath)) {
      [void]$(New-Item -ItemType 'Directory' -Path $directoryPath);
    }

    return $directoryPath;
  }

  [PSCustomObject] Load([string]$name, [boolean]$ifEmoji) {
    [string]$fullPath = $this.FullPath($name, $ifEmoji);
    [PSCustomObject]$options = if (Test-Path -LiteralPath $fullPath) {
      [string]$json = Get-Content -LiteralPath $fullPath;
      [string]$schemaPath = Join-Path -Path $PSScriptRoot `
        -ChildPath $([PoShLogProfile]::OPTIONS_SCHEMA_FILENAME);
      $null = Test-Json -Json $json -SchemaFile $schemaPath;

      $temp = $($json | ConvertFrom-Json -Depth 20);
      $this.Init($temp);
    }

    return $options;
  }

  [PSCustomObject] Init([PSCustomObject]$options) {
    $options = $this.restoreTypes($options);
    $this.verify($options);

    3..6 | Foreach-Object {
      [string]$headingType = "H$($_)";
      [string]$injection = $this.injectSegment(
        $options.Output.Headings.$headingType, $headingType, $options.Output.GroupBy
      );
      $options.Output.Headings.$headingType = $injection;
    }

    return $options;
  }

  [PSCustomObject] Eject([string]$name, [boolean]$ifEmoji) {
    [PSCustomObject]$options = $this.NewOptions($name, $ifEmoji);
    $this.Save($name, $ifEmoji, $options);

    $options.Output.Template = $this.Template();
    return $options;
  }

  [void] Save([string]$name, [boolean]$ifEmoji, [PSCustomObject]$options) {
    [string]$fullPath = $this.FullPath($name, $ifEmoji);
    [string]$extension = [System.IO.Path]::GetExtension($fullPath);
    [string]$resolvedName = [System.IO.Path]::GetFileName($fullPath);
    [string]$withoutExtension = [System.IO.Path]::GetFileNameWithoutExtension($resolvedName);
    [string]$testPath = $fullPath;
    [boolean]$verified = $false;
    [string]$alternate = [string]::Empty
    [int]$appendage = 0;

    do {
      if (Test-Path -LiteralPath $testPath) {
        $appendage++;
        $alternate = "$($withoutExtension)-{0:d2}$($extension)" -f $appendage;
        $testPath = $this.DirectoryPath($alternate);
      }
      else {
        $verified = $true;
      }
    } while (-not($verified));

    if (Test-Path -LiteralPath $fullPath) {
      Rename-Item -LiteralPath $fullPath `
        -NewName $([System.IO.Path]::GetFileName($testPath));
    }

    $content = $options | ConvertTo-Json -Depth 20;
    $this.EnsureRepoDirectoryPath();
    Set-Content -LiteralPath $fullPath -Value $content;
  }

  [object] Template() {
    [string]$templateName = [PoShLogProfile]::TEMPLATE_FILENAME;
    [string]$templatePath = $this.DirectoryPath($templateName);

    [object]$content = if (Test-Path -LiteralPath $templatePath) {
      Get-Content -LiteralPath $templatePath -Raw;
    }
    else {
      Set-Content -LiteralPath $templatePath -Value $([PoShLogOptionsManager]::DEFAULT_TEMPLATE);
      [PoShLogOptionsManager]::DEFAULT_TEMPLATE;
    }

    [string]$format = [PoShLogProfile]::MD_CONTENT_FORMAT;
    'warnings', 'content', 'links', 'schema-version' | Foreach-Object {
      if (-not($content.ToString().Contains($($format -f $_)))) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "PoShLogOptionsManager.Template: error in template file ($($templateName))" +
            ", missing '$($_)' placeholder"
          )
        );
      }
    }

    return $content;
  }

  [boolean] IsValidGroupBy([string]$groupBy) {
    [string[]]$segments = $groupBy -split '/';

    if ($segments.Count -gt 4) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        "GroupBy '$GroupBy' is invalid, can contain at most 4 segments");
    }

    [int]$uniqueCount = $($segments | Select-Object -Unique).Count;

    if ($uniqueCount -ne $segments.Count) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        "GroupBy '$GroupBy' is invalid, contains duplicate segments");
    }

    return $($null -eq ($segments | Where-Object {
          [PoShLogProfile]::GetSegments($this._segments) -notContains $_;
        }))
  }

  [PSCustomObject] FindOptions([string]$name, [boolean]$ifEmoji) {
    [PSCustomObject]$options = $this.Load($name, $ifEmoji);

    if ($null -eq $options) {
      $this.Found = $false;

      $options = $this.NewOptions($name, $ifEmoji);

      if ($null -ne $options) {
        $this.Save($name, $ifEmoji, $options);
      }
    }
    else {
      $this.Found = $true;
    }

    $options.Output.Template = $this.Template();
    return $options;
  }

  [PSCustomObject] NewOptions([string]$name, [boolean]$ifEmoji) {
    [string]$defaultHeadingStmt = [PoShLogProfile]::StatementPlaceholder();

    [PSCustomObject]$skeleton = [PSCustomObject]@{
      PSTypeName    = 'PoShLog.Options';
      #
      Snippet       = [PSCustomObject]@{
        PSTypeName = 'PoShLog.Options.Snippet';
        #
        Prefix     = [PSCustomObject]@{
          PSTypeName    = 'PoShLog.Options.Snippet.Prefix';
          #
          Conditional   = '?'; # breakStmt
          Literal       = '!'; # Anything in Output.Literals
          Lookup        = '&'; # Anything inside Output.Lookup
          NamedGroupRef = '^'; # Any named group ref inside include regex(s)
          Statement     = '*'; # Output.Statements
          Variable      = '+'; # (type, scope, change, link, tag, date, avatar) (resolved internally)
        }
      } # Snippet

      Selection     = [PSCustomObject]@{
        PSTypeName          = 'PoShLog.Options.Selection';
        #
        Order               = 'desc';
        SquashBy            = '#(?<issue>\d{1,6})'; # optional field
        Last                = $true;
        IncludeMissingIssue = $true;
        Subject             = [PSCustomObject]@{
          PSTypeName = 'PoShLog.Options.Selection.Subject';
          #
          Include    = @(
            # feat(foo)!: Add new bar (#42)
            #
            $(
              '^(?<type>fix|feat|build|chore|ci|docs|doc|style|ref|perf|test)' +
              '(?:\((?<scope>[\w]+)\))?(?<break>!)?:\s(?<body>[^\(]+)(?:\(?#(?<issue>\d{1,6})\)?)'
            )

            # feat(foo)!: #42 Add new bar
            #
            $(
              '^(?<type>fix|feat|build|chore|ci|docs|doc|style|ref|perf|test)' +
              '(?:\((?<scope>[\w]+)\))?(?<break>!)?:\s(?:#(?<issue>\d{1,6}))(?<body>[\w\W\s]+)'
            ),

            # (feat #42)!: Add new bar
            #
            $(
              '^\(?(?<type>fix|feat|build|chore|ci|docs|doc|style|ref|perf|test)' +
              '\s+(?:#(?<issue>\d{1,6}))?\)?(?<break>!)?:\s(?<body>[\w\W\s]+)'
            )
          );
          Exclude    = @();
        }

        Tags                = [PSCustomObject]@{
          PSTypeName = 'PoShLog.Options.Selection.Tags';
          #
          # FROM, commits that come after the TAG
          # UNTIL, commits up to and including TAG
          #
          # In these tests, there is no default, however, when we generate
          # the default config, the default here will be Until = 'HEAD',
          # which means get everything
          #
        }
      } # Selection

      SourceControl = [PSCustomObject]@{
        PSTypeName   = 'PoShLog.Options.SourceControl';
        #
        Service      = 'GitHub';
        HostUrl      = 'https://github.com/';
        AvatarSize   = '24';
        CommitIdSize = 7;
      } # SourceControl

      Output        = [PSCustomObject]@{
        PSTypeName = 'PoShLog.Options.Output';
        #
        # special variables:
        # -> &{_A} = author => indexes into the Authors hash
        # -> &{_B} = break => indexes into the BreakingStatus hash
        # -> &{_C} = change => indexes into the Change hash
        # -> &{_S} = scope => indexes into the Scopes hash if defined
        # -> &{_T} = type => indexes into the Types hash
        #
        Headings   = [PSCustomObject]@{
          PSTypeName = 'PoShLog.Options.Output.Headings';
          #
          H2         = 'Release [+{display-tag}] / +{date}';
          H3         = $defaultHeadingStmt;
          H4         = $defaultHeadingStmt;
          H5         = $defaultHeadingStmt;
          H6         = $defaultHeadingStmt;
          Dirty      = 'DIRTY: *{dirtyStmt}';
        }  # Headings

        Sections   = [PSCustomObject]@{
          PSTypeName = 'PoShLog.Options.Output.Sections';
          #
          Release    = [PSCustomObject]@{
            PSTypeName       = 'PoShLog.Options.Output.Sections.Release';
            #
            Highlights       = '*{highlightsStmt}';
            HighlightContent = @('', '*{highlightDummy}');
          }
        }

        GroupBy    = 'scope/type/change/break';

        LookUp     = [PSCustomObject]@{ # => '&'
          PSTypeName     = 'PoShLog.Options.Output.Lookup';
          #
          # => &{_A} ('_A' is a synonym of 'author')
          #
          Authors        = @{
            '?' = $this.useEmoji($ifEmoji, ':woman_office_worker:');
          }
          # => &{_B} ('_B' is a synonym of 'break')
          # In the regex, breaking change is indicated by ! (in accordance with
          # established wisdom) and this is translated into 'breaking', and if
          # missing, 'non-breaking', hence the following loop up keys.
          #
          BreakingStatus = @{
            'breaking'     = $this.useEmoji($ifEmoji, ':radioactive: BREAKING CHANGES', 'BREAKING CHANGES');
            'non-breaking' = $this.useEmoji($ifEmoji, ':recycle: NON BREAKING CHANGES', 'NON BREAKING CHANGES');
          }
          # => &{_C} ('_C' is a synonym of 'change')
          #
          ChangeTypes    = @{ # The first word in the commit subject after 'type(scope): '
            'Add'       = $this.useEmoji($ifEmoji, ':heavy_plus_sign:');
            'Change'    = $this.useEmoji($ifEmoji, ':o:');
            'Fixed'     = $this.useEmoji($ifEmoji, ':beetle:');
            'Deprecate' = $this.useEmoji($ifEmoji, ':heavy_multiplication_x:');
            'Remove'    = $this.useEmoji($ifEmoji, ':heavy_minus_sign:');
            'Secure'    = $this.useEmoji($ifEmoji, ':key:');
            'Update'    = $this.useEmoji($ifEmoji, 'isa:Change');
            '?'         = $this.useEmoji($ifEmoji, ':lock:');
          }

          # => &{_S} ('_S' is a synonym of 'scope')
          #
          Scopes         = @{
            # this is user defined. It should be maintained. Known scopes in
            # the project should be defined here
            #
            'all' = $this.useEmoji($ifEmoji, ':star:');
            '?'   = $this.useEmoji($ifEmoji, ':lock:');
          }

          # => &{_T} ('_T' is a synonym of 'type')
          # (These types must be consistent with includes regex)
          #
          Types          = @{
            'fix'   = $this.useEmoji($ifEmoji, ':heavy_check_mark:');
            'feat'  = $this.useEmoji($ifEmoji, ':gift:');
            'build' = $this.useEmoji($ifEmoji, ':hammer:');
            'chore' = $this.useEmoji($ifEmoji, ':nut_and_bolt:');
            'ci'    = $this.useEmoji($ifEmoji, ':trophy:');
            'doc'   = $this.useEmoji($ifEmoji, 'isa:docs');
            'docs'  = $this.useEmoji($ifEmoji, ':clipboard:');
            'style' = $this.useEmoji($ifEmoji, ':hotsprings:');
            'ref'   = $this.useEmoji($ifEmoji, ':gem:');
            'perf'  = $this.useEmoji($ifEmoji, ':rocket:');
            'test'  = $this.useEmoji($ifEmoji, ':test_tube:');
            '?'     = $this.useEmoji($ifEmoji, ':lock:');
          }
        } # Lookup

        Literals   = [PSCustomObject]@{ # => '!'
          PSTypeName    = 'PoShLog.Options.Output.Literals';
          #
          Broken        = $this.useEmoji($ifEmoji, ':warning:', 'break');
          BucketEnd     = '---';
          DateFormat    = 'yyyy-MM-dd';
          Dirty         = $this.useEmoji($ifEmoji, ':poop:', 'dirty');
          Uncategorised = 'uncategorised';
        } # Literals

        Statements = [PSCustomObject]@{ # => '*'
          PSTypeName     = 'PoShLog.Options.Output.Statements';
          #
          # These are overwritten but specified here as a reference to all
          # valid fields.
          #
          ActiveScope    = "+{scope}";
          Author         = ' by `@+{author}` &{_A}';
          Avatar         = ' by `@+{author}` +{avatar-img}';
          Break          = '&{_B}';
          Breaking       = '!{broken} *BREAKING CHANGE* ';
          Change         = 'Change Type(&{_C}+{change})';
          ChangeCommit   = '&{_C} ';
          Commit         = '+ ?{is-breaking;breakingStmt}?{is-squashed;squashedStmt}?{change;changeCommitStmt}*{subjectStmt}*{avatarStmt}*{metaStmt}';
          Dirty          = '!{dirty}';
          DirtyCommit    = '+ ?{is-breaking;breakingStmt}+{subject}';
          Highlights     = $this.useEmoji($ifEmoji, ':sparkles: HIGHLIGHTS', 'HIGHLIGHTS');
          HighlightDummy = '+ Lorem ipsum dolor sit amet';
          IssueLink      = ' \<**+{issue-link}**\>';
          Meta           = ' (Id: **+{commitid-link}**)?{issue-link;issueLinkStmt}';
          Scope          = 'Scope(&{_S}?{scope;activeScopeStmt;Uncategorised})';
          Squashed       = 'SQUASHED: ';
          Subject        = '**^{body}**';
          Type           = 'Commit Type(&{_T}+{type})';
          Ungrouped      = 'UNGROUPED';
        } # Statements

        Warnings   = [PSCustomObject]@{
          PSTypeName = 'PoShLog.Options.Output.Warnings';
          Disable    = @{
            'MD013' = 'line-length';
            'MD024' = 'no-duplicate-heading/no-duplicate-header';
            'MD026' = 'no-trailing-punctuation';
            'MD033' = 'no-inline-html';
          }
        } # Warnings

        Base       = 'ChangeLog';
        Template   = @();
      } # Output
    } # Skeleton options

    [PSCustomObject]$options = switch ($name) {
      'Alpha' {
        $skeleton.Output.Statements.Commit = $(
          "+ ?{scope;scopeCommitStmt}?{is-breaking;breakingStmt}?{is-squashed;squashedStmt}" +
          "?{change;changeCommitStmt}*{subjectStmt}?{issue-link;issueOnlyStmt}"
        );
        $skeleton.Output.Statements | Add-Member `
          -NotePropertyName 'ScopeCommit' -NotePropertyValue '***+{scope}***:';

        $skeleton.Output.Statements | Add-Member `
          -NotePropertyName 'IssueOnly' -NotePropertyValue '*{IssueLink}';
        $skeleton;

        break;
      }

      'Elizium' {
        $skeleton.Output.Statements.Commit = $(
          "+ ?{is-breaking;breakingStmt}?{is-squashed;squashedStmt}" +
          "?{change;changeCommitStmt}*{subjectStmt}*{avatarStmt}*{metaStmt}"
        );
        $skeleton.Selection | Add-Member `
          -NotePropertyName 'Change' -NotePropertyValue '^[\w]+'
        $skeleton;

        break;
      }

      'Test' {
        $skeleton.Output.Statements.Commit = $(
          "+ ?{is-breaking;breakingStmt}?{is-squashed;squashedStmt}" +
          "*{changeStmt}*{subjectStmt}*{authorStmt}*{metaStmt}"
        );
        $skeleton;

        break;
      }

      'Zen' {
        $skeleton.Output.Statements.Commit = $(
          "+ ?{is-breaking;breakingStmt}?{is-squashed;squashedStmt}" +
          "?{change;changeCommitStmt}*{subjectStmt}"
        );

        $skeleton;

        break;
      }

      default {
        $skeleton;
      }
    }

    return $options;
  } # NewOptions

  [string] useEmoji([boolean]$emojiRequired, [string]$emojiValue) {
    return $this.useEmoji($emojiRequired, $emojiValue, [string]::Empty);
  }

  [string] useEmoji([boolean]$emojiRequired, [string]$emojiValue, [string]$otherwise) {
    return $emojiRequired ? $emojiValue : $otherwise;
  }

  [string] injectSegment([string]$headingStatement, [string]$headingType, [string]$groupByValue) {
    return $this.injectSegment($headingStatement, $headingType, $groupByValue, 'NONE');
  }

  # eg headingStatement = 'HEADING: *{$}' => 'HEADING: *{scopeStmt}'
  #
  [string] injectSegment([string]$headingStatement, [string]$headingType,
    [string]$groupByValue, [string]$otherwise) {

    [string]$placeholder = [PoShLogProfile]::StatementPlaceholder();

    if (-not($headingStatement.Contains($placeholder))) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        $(
          "PoShLogOptionsManager.restoreTypeName: error in options file" +
          ", header-$($headingType) ($headingStatement) is missing statement placeholder ($placeholder)"
        )
      );
    }

    [string[]]$segments = $groupByValue -split '/';

    # eg:
    # H3 = '*{scopeStmt}';
    # H4 = '*{typeStmt}';
    # H5 = '*{breakingStmt}'
    # H6 = '*{changeStmt}';
    #
    [string]$heading = if ($segments -and ($segments.Count -gt 0)) {
      if ($segments.Count -eq 1) {
        [PoShLogProfile]::Snippet('*', $($segments[0] + 'Stmt'));
      }
      else {
        # eg:
        # H3 /H4 /H5 /H6
        # scope/type/break/change
        #
        [int]$headingNumeral = [int]::Parse($headingType[1]);
        
        if ($headingNumeral -in 3..6) {
          [int]$index = $($headingNumeral - 3);

          if ($index -lt $segments.Count) {
            [PoShLogProfile]::Snippet(
              '*', $($segments[$($headingNumeral - 3)] + 'Stmt')
            );
          }
          else {
            $otherwise;
          }
        }
        else {
          $otherwise;
        }
      }
    }
    else {
      $otherwise;
    }
    
    return $headingStatement.Replace($placeholder, $heading);
  }

  [PSCustomObject] restoreTypes([PSCustomObject]$options) {
    # This is required because ConvertTo-Json/ConvertFrom-Json fails to preserve
    # the PSTypeName members.
    #
    $this.restoreTypeName($options, 'Options');
    $this.restoreTypeName(${options}?.Snippet, 'Options.Snippet');
    $this.restoreTypeName(${options}?.Snippet?.Prefix, 'Options.Snippet.Prefix');
    $this.restoreTypeName(${options}?.Selection, 'Options.Selection');
    $this.restoreTypeName(${options}?.Selection?.Tags, 'Options.Selection.Tags');
    $this.restoreTypeName(${options}?.SourceControl, 'Options.SourceControl');

    [PSCustomObject]$output = ($options)?.Output;
    if ($null -ne $output) {
      $this.restoreTypeName($output, 'Options.Output');
      $this.restoreTypeName(${output}?.Headings, 'Options.Output.Headings');
      $this.restoreTypeName(${output}?.Lookup, 'Options.Output.Lookup');
      $this.restoreTypeName(${output}?.Literals, 'Options.Output.Literals');
      $this.restoreTypeName(${output}?.Statements, 'Options.Output.Statements');
      $this.restoreTypeName(${output}?.Warnings, 'Options.Output.Warnings');
    }
    else {
      throw [System.Management.Automation.MethodInvocationException]::new(
        $(
          "PoShLogOptionsManager.restoreTypes: error in options file" +
          ", missing 'Output' entry"
        )
      );
    }

    # Repair the hashtables
    #
    if (${output}?.Lookup -and (-not($output.Lookup -is [PSCustomObject]))) {
      throw [System.Management.Automation.MethodInvocationException]::new(
        $(
          "PoShLogOptionsManager.restoreTypes: error in options file" +
          ", Output.Lookup is not an object (type: '$($output.Lookup.GetType())')"
        )
      );
    }

    [PSCustomObject]$lookup = ${output}?.Lookup;
    $this.repairHashTable($lookup, 'Authors', $true);
    $this.repairHashTable($lookup, 'BreakingStatus', $false);
    $this.repairHashTable($lookup, 'ChangeTypes', $true);
    $this.repairHashTable($lookup, 'Scopes', $true);
    $this.repairHashTable($lookup, 'Types', $true);
    $this.repairHashTable(${output}?.Warnings, 'Disable', $false);

    return $options;
  }

  [void] restoreTypeName([object]$node, [string]$path) {
    try {
      if (-not($node -is [PSCustomObject])) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "PoShLogOptionsManager.restoreTypeName: error in options file" +
            ", item at path: '$path' is not an object"
          )
        );
      }

      if ($null -ne $node) {
        $node.PSObject.TypeNames.Insert(0, "PoShLog.$path");
      }
      else {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "PoShLogOptionsManager.restoreTypeName: error in options file" +
            ", missing entry at: '$path'"
          )
        );
      }
    }
    catch {
      throw [System.Management.Automation.MethodInvocationException]::new(
        $(
          "PoShLogOptionsManager.restoreTypeName: failed to restore type for '$path'" +
          ", error in options file."
        )
      );
    }
  }

  [void] repairHashTable([object]$node, [string]$name, [boolean]$withDefaultCheck) {
    # This method is required because ConvertTo-Json/ConvertFrom-Json fails to preserve
    # hashtables. To be fair, there is no distinction between a hashtable and
    # PSCustomObject in JSON notation, so there is no way to know what type an entry should
    # be. Using -AsHashTable on ConvertFrom-Json is of no use because it not selective. It
    # would convert everything to hashtables which is not what we want. So we convert
    # individual entries manually ourself.
    #
    if ($null -ne $node) {
      if (-not($node -is [PSCustomObject])) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "PoShLogOptionsManager.restoreTypeName: error in options file" +
            ", item '$name' is not an object"
          )
        );
      }

      if ($node.$name -is [PSCustomObject]) {
        [PSCustomObject]$target = $node.$name;
        [hashtable]$hash = @{}

        foreach ($property in $target.psobject.properties.name) {
          $hash[$property] = $target.$property;
        }
        $node.$name = $hash;

        if ($withDefaultCheck) {
          if (-not($hash.ContainsKey([PoShLogProfile]::LOOKUP_UNKNOWN))) {
            throw [System.Management.Automation.MethodInvocationException]::new(
              $(
                "PoShLogOptionsManager.repairHashTable: error in options file" +
                ", '$([PoShLogProfile]::LOOKUP_UNKNOWN)' entry for: '$name'"
              )
            );
          }
        }
      }
      else {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "PoShLogOptionsManager.repairHashTable: error in options file" +
            ", entry for: '$name' is not an object (type: '$($node.$name.GetType())')"
          )
        ); 
      }
    }
    else {
      throw [System.Management.Automation.MethodInvocationException]::new(
        $(
          "PoShLogOptionsManager.repairHashTable: error in options file" +
          ", missing hashtable entry for: '$name'"
        )
      );
    }
  }

  [void] verify([PSCustomObject]$options) {
    [array]$checklist = @(
      # Output
      #
      @{ Node = $options.Output; Member = 'GroupBy'; Type = [string]; Path = './Output'; },

      # Output.Selection
      #
      @{ Node = $options.Selection; Member = 'Subject'; Type = [PSCustomObject]; Path = './Selection'; },
      @{ Node = $options.Selection; Member = 'Tags'; Type = [PSCustomObject]; Path = './Tags'; },

      # Output.SourceControl
      #
      @{ Node = $options.SourceControl; Member = 'AvatarSize'; Type = [string]; Path = './SourceControl'; },
      @{ Node = $options.SourceControl; Member = 'CommitIdSize'; Type = [long]; Path = './SourceControl'; },

      # Output.Headings
      #
      @{ Node = $options.Output.Headings; Member = 'Dirty'; Type = [string]; Path = './Output/Headings'; }

      # Output.Sections
      #
      @{ Node = $options.Output.Sections; Member = 'Release'; Type = [PSCustomObject]; Path = './Output/Sections'; }

      # Output.Sections.Release
      #
      @{ Node = $options.Output.Sections.Release; Member = 'Highlights'; Type = [string]; Path = './Output/Sections/Release'; }
      @{ Node = $options.Output.Sections.Release; Member = 'HighlightContent'; Type = [array]; Path = './Output/Sections/Release'; }

      # Output.Literals
      #
      @{ Node = $options.Output.Literals; Member = 'Broken'; Type = [string]; Path = './Output.Literals'; },
      @{ Node = $options.Output.Literals; Member = 'BucketEnd'; Type = [string]; Path = './Output.Literals'; },
      @{ Node = $options.Output.Literals; Member = 'DateFormat'; Type = [string]; Path = './Output.Literals'; },
      @{ Node = $options.Output.Literals; Member = 'Dirty'; Type = [string]; Path = './Output.Literals'; },
      @{ Node = $options.Output.Literals; Member = 'Uncategorised'; Type = [string]; Path = './Output.Literals'; },

      # Output.Statements
      #
      @{ Node = $options.Output.Statements; Member = 'Break'; Type = [string]; Path = './Output.Statements'; },
      @{ Node = $options.Output.Statements; Member = 'Change'; Type = [string]; Path = './Output.Statements'; },
      @{ Node = $options.Output.Statements; Member = 'Scope'; Type = [string]; Path = './Output.Statements'; },
      @{ Node = $options.Output.Statements; Member = 'Type'; Type = [string]; Path = './Output.Statements'; },
      @{ Node = $options.Output.Statements; Member = 'Commit'; Type = [string]; Path = './Output.Statements'; },
      @{ Node = $options.Output.Statements; Member = 'DirtyCommit'; Type = [string]; Path = './Output.Statements'; }
    );

    $checklist | ForEach-Object {
      $this.verifyIsPresent($_.Node, $_.Member, $_.Type, $_.Path);
    }    
  }

  [void] verifyIsPresent ([PSCustomObject]$node, [string]$member, [Type]$type, [string]$path) {
    if ($null -ne $node.$member) {
      if (-not($node.$member -is $type)) {
        throw [System.Management.Automation.MethodInvocationException]::new(
          $(
            "PoShLogOptionsManager.verifyIsPresent: error in options file" +
            ", '$member' at '$path' is of wrong type, expected: " +
            "'$($type)', found: '$($node.$member.GetType())'"
          )
        );
      }
    }
    else {
      throw [System.Management.Automation.MethodInvocationException]::new(
        $(
          "PoShLogOptionsManager.verifyIsPresent: error in options file" +
          ", missing '$member' at '$path'"
        )
      );
    }
  }

  static [string] $DEFAULT_TEMPLATE = `
    @"
# Changelog
 
All notable changes to this project will be documented in this file.
 
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
[[warnings]]
[[content]]
[[links]]
[[schema-version]]
Powered By [:scroll: Elizium.PoShLog](https://github.com/EliziumNet/PoShLog)
"@

} # PoShLogOptionsManager

# === [ LineAppender ] =========================================================
# Prevents 2 consecutive blank lines from being created in the string builder
# and relieves the user from having to work out correct statement definitions
# that avoids consecutive blanks lines which then go on to cause a markdown
# warning.
#
class LineAppender {
  hidden [System.Text.StringBuilder]$_builder;
  hidden [string]$_previous = 'I Wish I Had Duck Feet';

  LineAppender() {
    $this._builder = [System.Text.StringBuilder]::new();
  }

  [void] AppendLine([string]$value) {
    if (-not([string]::IsNullOrEmpty($this._previous) -and ([string]::IsNullOrEmpty($value)))) {
      $this._builder.AppendLine($value);
    }

    $this._previous = $value;
  }

  [string] ToString() {
    return $this._builder.ToString();
  }
}
Export-ModuleMember -Variable *

Export-ModuleMember -Alias plog

Export-ModuleMember -Function Build-PoShLog, New-PoShLog, New-PoShLogOptionsManager, New-ProxyGit

# Custom Module Initialisation
#

Register-CommandSignals -Alias 'plog' -UsedSet 'PLOG', 'EJECT' -Silent