<#
.SYNOPSIS
    Pester tests for BloodHound Narrator.
#>

BeforeAll {
    $scriptRoot = Join-Path $PSScriptRoot '..' 'scripts'
    . ([ScriptBlock]::Create((Get-Content -Path (Join-Path $scriptRoot 'lib' 'SeverityClassifier.txt') -Raw)))
    . ([ScriptBlock]::Create((Get-Content -Path (Join-Path $scriptRoot 'lib' 'NarrativeTemplates.txt') -Raw)))

    $testJson   = Join-Path $PSScriptRoot 'synthetic-bloodhound.json'
    $raw        = Get-Content -Path $testJson -Raw | ConvertFrom-Json
    $script:Paths = $raw.paths

    $adcsJson       = Join-Path $PSScriptRoot 'synthetic-adcs.json'
    $adcsRaw        = Get-Content -Path $adcsJson -Raw | ConvertFrom-Json
    $script:AdcsPaths = $adcsRaw.paths
}

Describe 'SeverityClassifier' {

    Describe 'Get-SafeProp' {
        It 'Returns value when property exists' {
            $obj = [PSCustomObject]@{ foo = 'bar' }
            Get-SafeProp $obj 'foo' | Should -Be 'bar'
        }
        It 'Returns $null when property is missing' {
            $obj = [PSCustomObject]@{ foo = 'bar' }
            Get-SafeProp $obj 'missing' | Should -BeNullOrEmpty
        }
        It 'Returns $null for null object' {
            Get-SafeProp $null 'anything' | Should -BeNullOrEmpty
        }
    }

    Describe 'Test-IsTier0Target' {
        It 'Identifies Domain Admins as Tier 0' {
            $node = [PSCustomObject]@{
                label = 'Group'
                props = [PSCustomObject]@{
                    name       = 'DOMAIN ADMINS@CORP.LOCAL'
                    admincount = $true
                }
            }
            Test-IsTier0Target -Node $node | Should -BeTrue
        }
        It 'Identifies Domain Controller as Tier 0' {
            $node = [PSCustomObject]@{
                label = 'Computer'
                props = [PSCustomObject]@{
                    name = 'DC01.CORP.LOCAL'
                    isDC = $true
                }
            }
            Test-IsTier0Target -Node $node | Should -BeTrue
        }
        It 'Does not flag regular user as Tier 0' {
            $node = [PSCustomObject]@{
                label = 'User'
                props = [PSCustomObject]@{
                    name       = 'JSMITH@CORP.LOCAL'
                    admincount = $false
                }
            }
            Test-IsTier0Target -Node $node | Should -BeFalse
        }
    }

    Describe 'Invoke-SeverityClassification' {
        BeforeAll {
            $script:Classified = Invoke-SeverityClassification -Paths $script:Paths
        }

        It 'Returns one result per input path' {
            $script:Classified.Count | Should -Be 5
        }

        It 'Results are sorted by score descending' {
            for ($i = 1; $i -lt $script:Classified.Count; $i++) {
                $script:Classified[$i].Score | Should -BeLessOrEqual $script:Classified[$i - 1].Score
            }
        }

        It 'Classifies path-001 (Kerberoastable SVC + DCSync) as Critical' {
            ($script:Classified | Where-Object PathId -eq 'path-001').Severity | Should -Be 'Critical'
        }

        It 'Classifies path-002 (GenericAll on DA) as Critical' {
            ($script:Classified | Where-Object PathId -eq 'path-002').Severity | Should -Be 'Critical'
        }

        It 'Classifies path-003 (Unconstrained delegation + DCSync) as Critical' {
            ($script:Classified | Where-Object PathId -eq 'path-003').Severity | Should -Be 'Critical'
        }

        It 'Classifies path-004 (WriteDacl + GPO + financial server) as High' {
            ($script:Classified | Where-Object PathId -eq 'path-004').Severity | Should -Be 'High'
        }

        It 'Classifies path-005 (Session hijack + PII server) as High' {
            ($script:Classified | Where-Object PathId -eq 'path-005').Severity | Should -Be 'High'
        }

        It 'Detects Kerberoastable factor on path-001' {
            ($script:Classified | Where-Object PathId -eq 'path-001').Factors | Should -Contain 'Kerberoastable'
        }

        It 'Detects DCSync factor on path-001' {
            ($script:Classified | Where-Object PathId -eq 'path-001').Factors | Should -Contain 'DCSync'
        }

        It 'Detects UnconstrainedDelegation factor on path-003' {
            ($script:Classified | Where-Object PathId -eq 'path-003').Factors | Should -Contain 'UnconstrainedDelegation'
        }

        It 'Detects SensitiveData factor on path-005' {
            ($script:Classified | Where-Object PathId -eq 'path-005').Factors | Should -Contain 'SensitiveData'
        }

        It 'Records correct edge chain for path-002' {
            ($script:Classified | Where-Object PathId -eq 'path-002').EdgeChain | Should -Be 'MemberOf -> GenericAll'
        }
    }


    Describe 'v1.0.2 regression guard - 1.0.1 severities must not drift' {
        # These five paths were classified by v1.0.1 as 3 Critical / 2 High.
        # Activating previously-dead edge weights must not silently re-rate
        # historical findings; a client comparing two reports would see
        # severity inflation with no change in their environment.
        BeforeAll { $script:Baseline = Invoke-SeverityClassification -Paths $script:Paths }

        It 'path-001 remains Critical' { ($script:Baseline | Where-Object PathId -eq 'path-001').Severity | Should -Be 'Critical' }
        It 'path-002 remains Critical' { ($script:Baseline | Where-Object PathId -eq 'path-002').Severity | Should -Be 'Critical' }
        It 'path-003 remains Critical' { ($script:Baseline | Where-Object PathId -eq 'path-003').Severity | Should -Be 'Critical' }
        It 'path-004 remains High'     { ($script:Baseline | Where-Object PathId -eq 'path-004').Severity | Should -Be 'High' }
        It 'path-005 remains High'     { ($script:Baseline | Where-Object PathId -eq 'path-005').Severity | Should -Be 'High' }
    }

    Describe 'v1.0.2 score ceiling' {
        It 'Never reports a score above 100' {
            $all = Invoke-SeverityClassification -Paths $script:Paths
            ($all | Measure-Object -Property Score -Maximum).Maximum | Should -BeLessOrEqual 100
        }
        It 'Flags a capped path and preserves the raw score' {
            $p3 = (Invoke-SeverityClassification -Paths $script:Paths | Where-Object PathId -eq 'path-003')
            $p3.ScoreCapped | Should -BeTrue
            $p3.RawScore    | Should -BeGreaterThan 100
        }
    }

    Describe 'v1.0.2 AD CS detection' {
        BeforeAll { $script:Adcs = Invoke-SeverityClassification -Paths $script:AdcsPaths }

        It 'Classifies ESC1 as Critical'            { ($script:Adcs | Where-Object PathId -eq 'adcs-001').Severity | Should -Be 'Critical' }
        It 'Emits the ADCSESC1 factor'              { ($script:Adcs | Where-Object PathId -eq 'adcs-001').Factors  | Should -Contain 'ADCSESC1' }
        It 'Classifies ESC4 as Critical'            { ($script:Adcs | Where-Object PathId -eq 'adcs-002').Severity | Should -Be 'Critical' }
        It 'Emits PKITemplateAbuse for flag writes' { ($script:Adcs | Where-Object PathId -eq 'adcs-002').Factors  | Should -Contain 'PKITemplateAbuse' }
    }

    Describe 'v1.0.2 delegation and credential detection' {
        BeforeAll { $script:Adcs = Invoke-SeverityClassification -Paths $script:AdcsPaths }

        It 'Detects RBCD via AddAllowedToAct' { ($script:Adcs | Where-Object PathId -eq 'adcs-003').Factors | Should -Contain 'RBCD' }
        It 'Rates the RBCD path at least High' { ($script:Adcs | Where-Object PathId -eq 'adcs-003').Severity | Should -BeIn @('High','Critical') }
        It 'Detects shadow credentials'        { ($script:Adcs | Where-Object PathId -eq 'adcs-004').Factors | Should -Contain 'ShadowCredentials' }
        It 'Detects LAPS password read'        { ($script:Adcs | Where-Object PathId -eq 'adcs-004').Factors | Should -Contain 'ReadLAPSPassword' }
    }

    Describe 'v1.0.2 robustness' {
        BeforeAll { $script:Adcs = Invoke-SeverityClassification -Paths $script:AdcsPaths }

        It 'Does not throw on an unparseable pwdlastset' {
            { Get-PathSeverity -Path ($script:AdcsPaths | Where-Object { $_.id -eq 'adcs-004' }) } | Should -Not -Throw
        }
        It 'Flags the unparseable date as a factor' {
            ($script:Adcs | Where-Object PathId -eq 'adcs-004').Factors | Should -Contain 'UnparseablePwdLastSet'
        }
        It 'Collects unrecognised edge labels' {
            Get-UnknownEdgeLabels | Should -Contain 'SomeFutureEdgeV7'
        }
        It 'Does not flag structural AD CS edges as unknown' {
            Get-UnknownEdgeLabels | Should -Not -Contain 'PublishedTo'
        }
        It 'Survives an edge referencing a node absent from the path' {
            $orphan = [PSCustomObject]@{
                id = 'orphan-1'; description = 'Edge with unresolved endpoint'
                nodes = @(
                    [PSCustomObject]@{ id = 'a'; label = 'User';  props = [PSCustomObject]@{ name = 'U1@CORP.LOCAL' } },
                    [PSCustomObject]@{ id = 'b'; label = 'Group'; props = [PSCustomObject]@{ name = 'DOMAIN ADMINS@CORP.LOCAL'; admincount = $true } }
                )
                edges = @( [PSCustomObject]@{ id = 'e1'; source = 'a'; target = 'MISSING'; label = 'GenericAll' } )
            }
            { Get-PathSeverity -Path $orphan } | Should -Not -Throw
        }
    }

    Describe 'Get-PathSeverity edge cases' {
        It 'Handles a path with no critical edges gracefully' {
            $minimalPath = [PSCustomObject]@{
                id          = 'path-edge-1'
                description = 'Minimal path'
                nodes       = @(
                    [PSCustomObject]@{ id = 'a'; label = 'User';     props = [PSCustomObject]@{ name = 'U1@CORP.LOCAL' } },
                    [PSCustomObject]@{ id = 'b'; label = 'Computer'; props = [PSCustomObject]@{ name = 'WKS01.CORP.LOCAL' } }
                )
                edges       = @(
                    [PSCustomObject]@{ id = 'e1'; source = 'a'; target = 'b'; label = 'AdminTo' }
                )
            }
            $result = Get-PathSeverity -Path $minimalPath
            $result.Severity | Should -BeIn @('Low', 'Medium')
            $result.HopCount | Should -Be 1
        }
    }
}

Describe 'NarrativeTemplates' {

    BeforeAll {
        $script:Classified = Invoke-SeverityClassification -Paths $script:Paths
    }

    Describe 'Get-ExecutiveSummary' {
        BeforeAll {
            $script:Summary = Get-ExecutiveSummary -Classified $script:Classified -Domain 'YOURBANK.LOCAL'
        }

        It 'Contains the domain name' {
            $script:Summary | Should -Match 'YOURBANK\.LOCAL'
        }

        It 'Contains the executive summary heading' {
            $script:Summary | Should -Match '## Executive Summary'
        }

        It 'Contains the severity table' {
            $script:Summary | Should -Match 'Critical \| 3'
        }

        It 'Includes immediate-action callout for critical paths' {
            $script:Summary | Should -Match 'Immediate action required'
        }
    }

    Describe 'New-BHNarratorReport' {
        BeforeAll {
            $script:Report = New-BHNarratorReport `
                -Classified $script:Classified `
                -Domain     'YOURBANK.LOCAL' `
                -ExportDate '2026-03-22T14:37:09Z' `
                -BHVersion  '5.4.1'
        }

        It 'Contains the report header' {
            $script:Report | Should -Match '# BloodHound Attack Path Assessment'
        }

        It 'Contains the appendix section' {
            $script:Report | Should -Match '# Appendix: Technical Remediation Playbook'
        }

        It 'Contains remediation steps for DCSync' {
            $script:Report | Should -Match 'Remove DCSync / Replication Permissions'
        }

        It 'Contains remediation steps for unconstrained delegation' {
            $script:Report | Should -Match 'Remediate Unconstrained Delegation'
        }

        It 'Contains remediation steps for sensitive data' {
            $script:Report | Should -Match 'Isolate Sensitive Data Assets'
        }

        It 'Maps Tier 0-qualified factors to remediation guidance' {
            # v1.0.1 emitted factors such as GenericAllOnTier0 and then failed
            # to find a matching guidance key, silently omitting remediation
            # from the highest-severity findings.
            Resolve-RemediationKey -Factor 'GenericAllOnTier0' | Should -Be 'GenericAll'
            Resolve-RemediationKey -Factor 'WriteDaclOnTier0'  | Should -Be 'WriteDacl'
        }

        It 'Renders remediation for a Tier 0 GenericAll path' {
            $p2  = ($script:Classified | Where-Object PathId -eq 'path-002')
            $out = Get-TechnicalRemediation -ClassifiedPath $p2
            $out | Should -Match 'Revoke GenericAll Permissions'
        }

        It 'Contains the local-analysis footer' {
            $script:Report | Should -Match 'All analysis performed locally'
        }

        It 'Includes BloodHound version in report' {
            $script:Report | Should -Match '5\.4\.1'
        }
    }
}

Describe 'Invoke-BHNarrator (end-to-end)' {
    BeforeAll {
        $testJson    = Join-Path $PSScriptRoot 'synthetic-bloodhound.json'
        $outFile     = Join-Path $TestDrive 'e2e-report.md'
        $scriptsDir  = Join-Path $PSScriptRoot '..' 'scripts'
        $shWrapper   = Join-Path $scriptsDir 'bh-narrator.sh'
    }

    It 'Generates a report file from synthetic data' {
        & bash $shWrapper -InputFile $testJson -OutputFile $outFile
        Test-Path $outFile | Should -BeTrue
    }

    It 'Returns expected classification via report content' {
        $outPath = Join-Path $TestDrive 'e2e2.md'
        & bash $shWrapper -InputFile $testJson -OutputFile $outPath
        $content = Get-Content $outPath -Raw
        # 3 Critical + 2 High = 5 findings
        ($content | Select-String -Pattern 'CRITICAL|HIGH' -AllMatches).Matches.Count | Should -BeGreaterOrEqual 5
    }

    It 'Respects -MinSeverity filter' {
        $outPath = Join-Path $TestDrive 'e2e3.md'
        & bash $shWrapper -InputFile $testJson -OutputFile $outPath -MinSeverity Critical
        $content = Get-Content $outPath -Raw
        $content | Should -Match 'CRITICAL'
        $content | Should -Not -Match '### .+ HIGH:'
    }

    It 'Report contains both CISO and appendix sections' {
        & bash $shWrapper -InputFile $testJson -OutputFile $outFile
        $content = Get-Content $outFile -Raw
        $content | Should -Match '## Executive Summary'
        $content | Should -Match '# Appendix: Technical Remediation Playbook'
    }
}
