using module ./SoftwareReport.BaseNodes.psm1

#########################################
### Nodes to describe image software ####
#########################################

# NodesFactory is used to simplify parsing different types of notes
# Every node has own logic of parsing and this method just invokes "FromJsonObject" of correct node type
class NodesFactory {
    static [BaseNode] ParseNodeFromObject([object] $JsonObj) {
        if ($JsonObj.NodeType -eq [HeaderNode].Name) {
            return [HeaderNode]::FromJsonObject($JsonObj)
        } elseif ($JsonObj.NodeType -eq [ToolVersionNode].Name) {
            return [ToolVersionNode]::FromJsonObject($JsonObj)
        } elseif ($JsonObj.NodeType -eq [ToolVersionsListNode].Name) {
            return [ToolVersionsListNode]::FromJsonObject($JsonObj)
        } elseif ($JsonObj.NodeType -eq [TableNode].Name) {
            return [TableNode]::FromJsonObject($JsonObj)
        } elseif ($JsonObj.NodeType -eq [NoteNode].Name) {
            return [NoteNode]::FromJsonObject($JsonObj)
        }

        throw "Unknown node type in ParseNodeFromObject '$($JsonObj.NodeType)'"
    }
}

class HeaderNode: BaseNode {
    [ValidateNotNullOrEmpty()]
    [String] $Title
    [Collections.Generic.List[BaseNode]] $Children

    HeaderNode([String] $Title) {
        $this.Title = $Title
        $this.Children = @()
    }

    [Boolean] ShouldBeIncludedToDiff() {
        return $true
    }

    [void] AddNode([BaseNode] $node) {
        $similarNode = $this.FindSimilarChildNode($node)
        if ($similarNode) {
            throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.`nFound node: $($similarNode.ToJsonObject() | ConvertTo-Json)`nNew node: $($node.ToJsonObject() | ConvertTo-Json)"
        }

        if (-not $this.IsNodeHasMarkdownHeader($node)) {
            # If the node doesn't print own header to markdown, we should check that there is no other nodes that print header to markdown before it.
            # It is done to avoid unexpected situation like this:
            #
            # HeaderNode A                 -> # A
            #   HeaderNode B               -> ## B
            #   ToolVersionNode C          -> - C
            # ToolVersionNode D            -> - D
            # 
            # In this example, we add 'HeaderNode B" to 'HeaderNode A' and add 'ToolVersionNode C' to 'HeaderNode B'.
            # Then we add 'ToolVersionNode D' to 'HeaderNode A'.
            # But the result markdown will look like 'ToolVersionNode D' belongs to 'HeaderNode B' instead of 'HeaderNode A'.
            $this.Children | Where-Object { $this.IsNodeHasMarkdownHeader($_) } | ForEach-Object {
                throw "It is not allowed to add the non-header node after the header node. Consider adding the separate HeaderNode for this node"
            }
        }

        $this.Children.Add($node)
    }

    [void] AddNodes([BaseNode[]] $nodes) {
        $nodes | ForEach-Object {
            $this.AddNode($_)
        }
    }

    [HeaderNode] AddHeader([String] $Title) {
        $node = [HeaderNode]::new($Title)
        $this.AddNode($node)
        return $node
    }

    [void] AddToolVersion([String] $ToolName, [String] $Version) {
        $this.AddNode([ToolVersionNode]::new($ToolName, $Version))
    }

    [void] AddToolVersionsList([String] $ToolName, [String[]] $Version, [String] $MajorVersionRegex) {
        $this.AddNode([ToolVersionsListNode]::new($ToolName, $Version, $MajorVersionRegex, "List"))
    }

    [void] AddToolVersionsListInline([String] $ToolName, [String[]] $Version, [String] $MajorVersionRegex) {
        $this.AddNode([ToolVersionsListNode]::new($ToolName, $Version, $MajorVersionRegex, "Inline"))
    }
     
    [void] AddTable([PSCustomObject[]] $Table) {
        $this.AddNode([TableNode]::FromObjectsArray($Table))
    }

    [void] AddNote([String] $Content) {
        $this.AddNode([NoteNode]::new($Content))
    }

    [String] ToMarkdown([Int32] $Level) {
        $sb = [System.Text.StringBuilder]::new()
        $sb.AppendLine()
        $sb.AppendLine("$("#" * $Level) $($this.Title)")
        $this.Children  | ForEach-Object {
            $sb.AppendLine($_.ToMarkdown($Level + 1))
        }

        return $sb.ToString().TrimEnd()
    }

    [PSCustomObject] ToJsonObject() {
        return [PSCustomObject]@{
            NodeType = $this.GetType().Name
            Title = $this.Title
            Children = $this.Children | ForEach-Object { $_.ToJsonObject() }
        }
    }

    static [HeaderNode] FromJsonObject([Object] $JsonObj) {
        $node = [HeaderNode]::new($JsonObj.Title)
        $JsonObj.Children | Where-Object { $_ } | ForEach-Object { $node.AddNode([NodesFactory]::ParseNodeFromObject($_)) }
        return $node
    }

    [Boolean] IsSimilarTo([BaseNode] $OtherNode) {
        if ($OtherNode.GetType() -ne [HeaderNode]) {
            return $false
        }

        return $this.Title -eq $OtherNode.Title
    }

    [Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
        return $this.IsSimilarTo($OtherNode)
    }

    [BaseNode] FindSimilarChildNode([BaseNode] $Find) {
        foreach ($childNode in $this.Children) {
            if ($childNode.IsSimilarTo($Find)) {
                return $childNode
            }
        }

        return $null
    }

    hidden [Boolean] IsNodeHasMarkdownHeader([BaseNode] $node) {
        if ($node -is [HeaderNode]) {
            return $true
        }

        if (($node -is [ToolVersionsListNode]) -and ($node.ListType -eq "List")) {
            return $true
        }

        return $false
    }
}

class ToolVersionNode: BaseToolNode {
    [ValidateNotNullOrEmpty()]
    [String] $Version

    ToolVersionNode([String] $ToolName, [String] $Version): base($ToolName) {

        if ([String]::IsNullOrEmpty($Version)) {
            throw "ToolVersionNode '$($this.ToolName)' has empty version"
        }

        $this.Version = $Version
    }

    [String] ToMarkdown([Int32] $Level) {
        return "- $($this.ToolName) $($this.Version)"
    }

    [String] GetValue() {
        return $this.Version
    }

    [PSCustomObject] ToJsonObject() {
        return [PSCustomObject]@{
            NodeType = $this.GetType().Name
            ToolName = $this.ToolName
            Version = $this.Version
        }
    }

    static [BaseNode] FromJsonObject([Object] $JsonObj) {
        return [ToolVersionNode]::new($JsonObj.ToolName, $JsonObj.Version)
    }
}

class ToolVersionsListNode: BaseToolNode {
    [ValidateNotNullOrEmpty()]
    [String[]] $Versions

    [Regex] $MajorVersionRegex

    [ValidateSet("List", "Inline")]
    [String] $ListType

    ToolVersionsListNode([String] $ToolName, [String[]] $Versions, [String] $MajorVersionRegex, [String] $ListType): base($ToolName) {
        $this.Versions = $Versions

         if ([String]::IsNullOrEmpty($Versions)) {
            throw "ToolVersionsListNode '$($this.ToolName)' has empty versions list"
        }

        $this.MajorVersionRegex = [Regex]::new($MajorVersionRegex)
        $this.ListType = $ListType
        $this.ValidateMajorVersionRegex()
    }

    [String] ToMarkdown([Int32] $Level) {
        if ($this.ListType -eq "Inline") {
            return "- $($this.ToolName): $($this.Versions -join ', ')"
        }

        $sb = [System.Text.StringBuilder]::new()
        $sb.AppendLine()
        $sb.AppendLine("$("#" * $Level) $($this.ToolName)")
        $this.Versions | ForEach-Object {
            $sb.AppendLine("- $_")
        }

        return $sb.ToString().TrimEnd()
    }

    [String] GetValue() {
        return $this.Versions -join ', '
    }

    [String] ExtractMajorVersion([String] $Version) {
        $match = $this.MajorVersionRegex.Match($Version)
        if (($match.Success -ne $true) -or [String]::IsNullOrEmpty($match.Groups[0].Value)) {
            throw "Version '$Version' doesn't match regex '$($this.PrimaryVersionRegex)'"
        }

        return $match.Groups[0].Value
    }

    [PSCustomObject] ToJsonObject() {
        return [PSCustomObject]@{
            NodeType = $this.GetType().Name
            ToolName = $this.ToolName
            Versions = $this.Versions
            MajorVersionRegex = $this.MajorVersionRegex.ToString()
            ListType = $this.ListType
        }
    }

    static [ToolVersionsListNode] FromJsonObject([Object] $JsonObj) {
        return [ToolVersionsListNode]::new($JsonObj.ToolName, $JsonObj.Versions, $JsonObj.MajorVersionRegex, $JsonObj.ListType)
    }

    hidden [void] ValidateMajorVersionRegex() {
        $this.Versions | Group-Object { $this.ExtractMajorVersion($_) } | ForEach-Object {
            if ($_.Count -gt 1) {
                throw "Multiple versions from list '$($this.GetValue())' return the same result from regex '$($this.MajorVersionRegex)': $($_.Name)"
            }
        }
    }
}

class TableNode: BaseNode {
    # It is easier to store the table as rendered lines because it will simplify finding differences in rows later
    [ValidateNotNullOrEmpty()]
    [String] $Headers
    [ValidateNotNullOrEmpty()]
    [String[]] $Rows

    TableNode([String] $Headers, [String[]] $Rows) {
        $this.Headers = $Headers
        $this.Rows = $Rows
        
        $columnsCount = $this.Headers.Split("|").Count
        $this.Rows | ForEach-Object {
            if ($_.Split("|").Count -ne $columnsCount) {
                throw "Table has different number of columns in different rows"
            }
        }
    }

    [Boolean] ShouldBeIncludedToDiff() {
        return $true
    }

    [String] ToMarkdown([Int32] $Level) {
        $maxColumnWidths = $this.CalculateColumnsWidth()
        $columnsCount = $maxColumnWidths.Count

        $delimiterLine = [String]::Join("|", @("-") * $columnsCount)

        $sb = [System.Text.StringBuilder]::new()
        @($this.Headers) + @($delimiterLine) + $this.Rows | ForEach-Object {
            $sb.Append("|")
            $row = $_.Split("|")

            for ($colIndex = 0; $colIndex -lt $columnsCount; $colIndex++) {
                $padSymbol = $row[$colIndex] -eq "-" ? "-" : " "
                $cellContent = $row[$colIndex].PadRight($maxColumnWidths[$colIndex], $padSymbol)
                $sb.Append(" $($cellContent) |")
            }
            
            $sb.AppendLine()
        }

        return $sb.ToString().TrimEnd()
    }

    hidden [Int32[]] CalculateColumnsWidth() {
        $maxColumnWidths = $this.Headers.Split("|") | ForEach-Object { $_.Length }
        $columnsCount = $maxColumnWidths.Count

        $this.Rows | ForEach-Object {
            $columnWidths = $_.Split("|") | ForEach-Object { $_.Length }
            for ($colIndex = 0; $colIndex -lt $columnsCount; $colIndex++) {
                $maxColumnWidths[$colIndex] = [Math]::Max($maxColumnWidths[$colIndex], $columnWidths[$colIndex])
            }
        }

        return $maxColumnWidths
    }

    [PSCustomObject] ToJsonObject() {
        return [PSCustomObject]@{
            NodeType = $this.GetType().Name
            Headers = $this.Headers
            Rows = $this.Rows
        }
    }

    static [TableNode] FromJsonObject([Object] $JsonObj) {
        return [TableNode]::new($JsonObj.Headers, $JsonObj.Rows)
    }

    [Boolean] IsSimilarTo([BaseNode] $OtherNode) {
        if ($OtherNode.GetType() -ne [TableNode]) {
            return $false
        }

        # We don't support having multiple TableNode instances on the same header level so such check is fine
        return $true
    }

    [Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
        if (-not $this.IsSimilarTo($OtherNode)) {
            return $false
        }

        # We don't compare $this.Headers intentionally
        # It is fine to ignore the tables where headers are changed but rows are not changed

        if ($this.Rows.Count -ne $OtherNode.Rows.Count) {
            return $false
        }

        for ($rowIndex = 0; $rowIndex -lt $this.Rows.Count; $rowIndex++) {
            if ($this.Rows[$rowIndex] -ne $OtherNode.Rows[$rowIndex]) {
                return $false
            }
        }

        return $true
    }

    static [TableNode] FromObjectsArray([PSCustomObject[]] $Table) {
        if ($Table.Count -eq 0) {
            throw "Failed to create TableNode from empty objects array"
        }

        [String] $tableHeaders = [TableNode]::ArrayToTableRow($Table[0].PSObject.Properties.Name)
        [Collections.Generic.List[String]] $tableRows = @()

        $Table | ForEach-Object {
            $rowHeaders = [TableNode]::ArrayToTableRow($_.PSObject.Properties.Name)
            if (($rowHeaders -ne $tableHeaders)) {
                throw "Failed to create TableNode from objects array because objects have different properties"
            }

            $tableRows.Add([TableNode]::ArrayToTableRow($_.PSObject.Properties.Value))
        }

        return [TableNode]::new($tableHeaders, $tableRows)
    }

    hidden static [String] ArrayToTableRow([String[]] $Values) {
        if ($Values.Count -eq 0) {
            throw "Failed to create TableNode because some objects are empty"
        }
        $Values | ForEach-Object {
            if ($_.Contains("|")) {
                throw "Failed to create TableNode because some cells '$_' contains forbidden symbol '|'"
            }
        }

        return [String]::Join("|", $Values)
    }
}

class NoteNode: BaseNode {
    [ValidateNotNullOrEmpty()]
    [String] $Content

    NoteNode([String] $Content) {
        $this.Content = $Content
    }

    [String] ToMarkdown([Int32] $Level) {
        return @(
            '```',
            $this.Content,
            '```'
        ) -join "`n"
    }

    [PSCustomObject] ToJsonObject() {
        return [PSCustomObject]@{
            NodeType = $this.GetType().Name
            Content = $this.Content
        }
    }

    static [NoteNode] FromJsonObject([Object] $JsonObj) {
        return [NoteNode]::new($JsonObj.Content)
    }

    [Boolean] IsSimilarTo([BaseNode] $OtherNode) {
        if ($OtherNode.GetType() -ne [NoteNode]) {
            return $false
        }

        return $this.Content -eq $OtherNode.Content
    }

    [Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
        return $this.IsSimilarTo($OtherNode)
    }
}
