###############################################################################
# windows-dns-smoke-tests.ps1 - Verify Windows DNS Server health
#
# Checks DNS service status, zone loading, AD-integrated zone replication,
# conditional forwarders, forward and reverse resolution, scavenging
# configuration, root hints, forwarder connectivity, zone delegation,
# cache statistics, and event log errors.
#
# Author: Phil Connor
# Contact: contact@mylinux.work
# License: MIT
# Version 1.0
#
# Usage:
#   .\windows-dns-smoke-tests.ps1
#   .\windows-dns-smoke-tests.ps1 -DnsServer 10.0.0.53 -Domain corp.local
#   .\windows-dns-smoke-tests.ps1 -OutputFormat tap
#   .\windows-dns-smoke-tests.ps1 -Help
###############################################################################

[CmdletBinding()]
param(
    [string]$DnsServer = "localhost",
    [string]$Domain = $(if ($env:USERDNSDOMAIN) { $env:USERDNSDOMAIN } else { "" }),
    [string]$TestDomain = "example.com",
    [ValidateSet("text","tap")]
    [string]$OutputFormat = "text",
    [switch]$NoColor,
    [switch]$Help
)

$ErrorActionPreference = "Continue"

$script:Pass = 0; $script:Fail = 0; $script:Skip = 0; $script:Total = 0
$script:Results = @(); $script:StartTime = $null

# ============================================================================
# COLORS
# ============================================================================

function Write-Color {
    param([string]$Text, [string]$Color = "White")
    if ($NoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color }
}

# ============================================================================
# TEST RESULT RECORDING
# ============================================================================

function Record-Pass {
    param([string]$Name, [string]$Detail = "")
    $script:Pass++; $script:Total++
    $script:Results += [PSCustomObject]@{ Status="PASS"; Name=$Name; Detail=$Detail }
    if ($OutputFormat -eq "tap") {
        Write-Host "ok $($script:Total) - $Name"
    } else {
        $msg = "  $(if($NoColor){'[PASS]'}else{[char]0x2713}) $Name"
        if ($Detail) { $msg += " - $Detail" }
        Write-Color $msg "Green"
    }
}

function Record-Fail {
    param([string]$Name, [string]$Detail = "")
    $script:Fail++; $script:Total++
    $script:Results += [PSCustomObject]@{ Status="FAIL"; Name=$Name; Detail=$Detail }
    if ($OutputFormat -eq "tap") {
        Write-Host "not ok $($script:Total) - $Name"
        if ($Detail) { Write-Host "  # $Detail" }
    } else {
        $msg = "  $(if($NoColor){'[FAIL]'}else{[char]0x2717}) $Name"
        if ($Detail) { $msg += " - $Detail" }
        Write-Color $msg "Red"
    }
}

function Record-Skip {
    param([string]$Name, [string]$Reason = "")
    $script:Skip++; $script:Total++
    $script:Results += [PSCustomObject]@{ Status="SKIP"; Name=$Name; Detail=$Reason }
    if ($OutputFormat -eq "tap") {
        Write-Host "ok $($script:Total) - $Name # SKIP $Reason"
    } else {
        $msg = "  $(if($NoColor){'[SKIP]'}else{[char]0x2298}) $Name"
        if ($Reason) { $msg += " - $Reason" }
        Write-Color $msg "Yellow"
    }
}

# ============================================================================
# HELPERS
# ============================================================================

function Get-DnsComputerParam {
    if ($DnsServer -eq "localhost" -or $DnsServer -eq "127.0.0.1" -or $DnsServer -eq "::1") {
        return @{}
    }
    return @{ ComputerName = $DnsServer }
}

function Write-Section {
    param([string]$Title)
    if ($OutputFormat -ne "tap") { Write-Host ""; Write-Color $Title "White" }
}

# ============================================================================
# TESTS
# ============================================================================

function Test-DnsService {
    Write-Section "DNS Service"
    try {
        $params = @{ Name = "DNS"; ErrorAction = "Stop" }
        if ($DnsServer -ne "localhost" -and $DnsServer -ne "127.0.0.1" -and $DnsServer -ne "::1") {
            $params["ComputerName"] = $DnsServer
        }
        $svc = Get-Service @params
        if ($svc.Status -eq "Running") { Record-Pass "DNS service running" }
        else { Record-Fail "DNS service running" "status: $($svc.Status)" }
    } catch { Record-Fail "DNS service running" $_.Exception.Message }
}

function Test-DnsResponds {
    try {
        $result = Resolve-DnsName -Name $TestDomain -Server $DnsServer -Type A -DnsOnly -ErrorAction Stop
        if ($result) { Record-Pass "DNS server responds" "$TestDomain resolved" }
        else { Record-Fail "DNS server responds" "no result for $TestDomain" }
    } catch { Record-Fail "DNS server responds" $_.Exception.Message }
}

function Test-ZonesLoaded {
    $cp = Get-DnsComputerParam
    try {
        $zones = @(Get-DnsServerZone @cp -ErrorAction Stop |
            Where-Object { $_.ZoneType -ne "Forwarder" -and $_.ZoneName -ne "TrustAnchors" })
        if ($zones.Count -gt 0) { Record-Pass "zones loaded" "$($zones.Count) zones" }
        else { Record-Fail "zones loaded" "no zones found" }
    } catch { Record-Fail "zones loaded" $_.Exception.Message }
}

function Test-ADIntegratedZones {
    if (-not $Domain) { Record-Skip "AD-integrated zones" "Domain not set"; return }
    $cp = Get-DnsComputerParam
    try {
        $zones = @(Get-DnsServerZone @cp -ErrorAction Stop |
            Where-Object { $_.IsAutoCreated -eq $false -and $_.IsDsIntegrated -eq $true })
        if ($zones.Count -eq 0) { Record-Skip "AD-integrated zones" "none found"; return }
        foreach ($z in $zones) {
            $scope = if ($z.DirectoryPartitionName) { $z.DirectoryPartitionName } else { "legacy" }
            Record-Pass "AD-integrated zone ($($z.ZoneName))" "replication: $scope"
        }
    } catch { Record-Fail "AD-integrated zones" $_.Exception.Message }
}

function Test-ForwardLookup {
    Write-Section "DNS Resolution"
    if (-not $Domain) { Record-Skip "forward lookup" "Domain not set"; return }
    try {
        $result = Resolve-DnsName -Name $Domain -Server $DnsServer -Type A -DnsOnly -ErrorAction Stop
        if ($result) {
            $addrs = ($result | Where-Object { $_.QueryType -eq "A" } |
                Select-Object -ExpandProperty IPAddress) -join ", "
            if ($addrs) { Record-Pass "forward lookup ($Domain)" $addrs }
            else { Record-Pass "forward lookup ($Domain)" "resolved (non-A records)" }
        } else { Record-Fail "forward lookup ($Domain)" "no result" }
    } catch { Record-Fail "forward lookup ($Domain)" $_.Exception.Message }
}

function Test-ReverseLookup {
    $cp = Get-DnsComputerParam
    try {
        $rz = @(Get-DnsServerZone @cp -ErrorAction Stop |
            Where-Object { $_.IsReverseLookupZone -eq $true -and $_.ZoneType -ne "Forwarder" })
        if ($rz.Count -eq 0) { Record-Skip "reverse lookup" "no reverse zones found"; return }
        $found = $false
        foreach ($zone in $rz) {
            try {
                $ptrs = Get-DnsServerResourceRecord -ZoneName $zone.ZoneName -RRType PTR @cp -ErrorAction Stop
                if ($ptrs) {
                    $first = $ptrs | Select-Object -First 1
                    Record-Pass "reverse lookup" "PTR $($first.HostName) -> $($first.RecordData.PtrDomainName)"
                    $found = $true; break
                }
            } catch { }
        }
        if (-not $found) { Record-Skip "reverse lookup" "no PTR records in reverse zones" }
    } catch { Record-Fail "reverse lookup" $_.Exception.Message }
}

function Test-ConditionalForwarders {
    Write-Section "Forwarders"
    $cp = Get-DnsComputerParam
    try {
        $fzones = @(Get-DnsServerZone @cp -ErrorAction Stop |
            Where-Object { $_.ZoneType -eq "Forwarder" })
        if ($fzones.Count -eq 0) { Record-Skip "conditional forwarders" "none configured"; return }
        foreach ($fz in $fzones) {
            $targets = $fz.MasterServers
            if (-not $targets -or @($targets).Count -eq 0) {
                Record-Fail "conditional forwarder ($($fz.ZoneName))" "no targets"; continue
            }
            $ok = $false
            foreach ($t in $targets) {
                try {
                    $r = Resolve-DnsName -Name $fz.ZoneName -Server $t.IPAddressToString -Type SOA -DnsOnly -ErrorAction Stop
                    if ($r) { $ok = $true; break }
                } catch { }
            }
            if ($ok) { Record-Pass "conditional forwarder ($($fz.ZoneName))" "target reachable" }
            else { Record-Fail "conditional forwarder ($($fz.ZoneName))" "no targets responded" }
        }
    } catch { Record-Fail "conditional forwarders" $_.Exception.Message }
}

function Test-RootHints {
    $cp = Get-DnsComputerParam
    try {
        $hints = @(Get-DnsServerRootHint @cp -ErrorAction Stop)
        if ($hints.Count -eq 0) { Record-Skip "root hints" "none configured"; return }
        Record-Pass "root hints configured" "$($hints.Count) root servers"
        $tested = $false
        foreach ($h in $hints) {
            foreach ($ip in ($h.IPAddress | Where-Object { $_.RecordType -eq "A" })) {
                try {
                    $addr = $ip.RecordData.IPv4Address.IPAddressToString
                    $r = Resolve-DnsName -Name "." -Server $addr -Type NS -DnsOnly -ErrorAction Stop
                    if ($r) { Record-Pass "root hint reachable" $addr; $tested = $true; break }
                } catch { }
            }
            if ($tested) { break }
        }
        if (-not $tested) { Record-Fail "root hint reachable" "no root servers responded" }
    } catch { Record-Fail "root hints" $_.Exception.Message }
}

function Test-Forwarders {
    $cp = Get-DnsComputerParam
    try {
        $fwd = Get-DnsServerForwarder @cp -ErrorAction Stop
        $ips = $fwd.IPAddress
        if (-not $ips -or @($ips).Count -eq 0) {
            Record-Skip "forwarder configuration" "no forwarders configured"; return
        }
        foreach ($ip in $ips) {
            $addr = $ip.IPAddressToString
            try {
                $r = Resolve-DnsName -Name $TestDomain -Server $addr -Type A -DnsOnly -ErrorAction Stop
                if ($r) { Record-Pass "forwarder responds ($addr)" }
                else { Record-Fail "forwarder responds ($addr)" "no response" }
            } catch { Record-Fail "forwarder responds ($addr)" $_.Exception.Message }
        }
    } catch { Record-Fail "forwarder configuration" $_.Exception.Message }
}

function Test-Scavenging {
    Write-Section "Server Configuration"
    $cp = Get-DnsComputerParam
    try {
        $scav = Get-DnsServerScavenging @cp -ErrorAction Stop
        if ($scav.ScavengingState -eq $true) {
            $detail = "interval: $($scav.ScavengingInterval)"
            $last = $scav.LastScavengeTime
            if ($last -and $last -ne [DateTime]::MinValue) {
                $days = [math]::Floor(((Get-Date) - $last).TotalDays)
                $detail += ", last run: $($last.ToString('yyyy-MM-dd')) (${days}d ago)"
                if ($days -gt 14) { Record-Fail "scavenging" "$detail — over 14 days ago"; return }
            } else { $detail += ", last run: never" }
            Record-Pass "scavenging enabled" $detail
        } else {
            Record-Pass "scavenging disabled" "not enabled on this server"
        }
    } catch { Record-Fail "scavenging" $_.Exception.Message }
}

function Test-ZoneDelegation {
    if (-not $Domain) { Record-Skip "zone delegation" "Domain not set"; return }
    $cp = Get-DnsComputerParam
    try {
        $ns = @(Get-DnsServerResourceRecord -ZoneName $Domain -RRType NS @cp -ErrorAction Stop |
            Where-Object { $_.HostName -ne "@" })
        if ($ns.Count -eq 0) { Record-Skip "zone delegation" "no delegated subdomains"; return }
        foreach ($r in $ns) {
            Record-Pass "zone delegation ($($r.HostName))" "NS -> $($r.RecordData.NameServer)"
        }
    } catch {
        if ($_.Exception.Message -match "not found") {
            Record-Skip "zone delegation" "zone $Domain not hosted on this server"
        } else { Record-Fail "zone delegation" $_.Exception.Message }
    }
}

function Test-CacheStats {
    Write-Section "Statistics"
    $cp = Get-DnsComputerParam
    try {
        $stats = Get-DnsServerStatistics @cp -ErrorAction Stop
        $cs = $stats.CacheStatistics
        if ($cs) {
            $hits = $cs.CacheHits; $misses = $cs.CacheMisses; $total = $hits + $misses
            if ($total -gt 0) {
                $ratio = [math]::Round(($hits / $total) * 100, 1)
                Record-Pass "cache statistics" "hits: $hits, misses: $misses, hit ratio: ${ratio}%"
            } else { Record-Pass "cache statistics" "no queries processed yet" }
        } elseif ($stats.QueryStatistics) {
            Record-Pass "cache statistics" "total queries: $($stats.QueryStatistics.TotalQueries)"
        } else { Record-Skip "cache statistics" "not available" }
    } catch { Record-Fail "cache statistics" $_.Exception.Message }
}

function Test-EventLog {
    $cp = Get-DnsComputerParam
    try {
        $filter = @{ LogName = "DNS Server"; Level = 2; StartTime = (Get-Date).AddHours(-24) }
        if ($cp.ContainsKey("ComputerName")) {
            $events = @(Get-WinEvent -FilterHashtable $filter -ComputerName $cp.ComputerName -ErrorAction Stop)
        } else {
            $events = @(Get-WinEvent -FilterHashtable $filter -ErrorAction Stop)
        }
        if ($events.Count -gt 0) {
            $latest = $events[0]
            $msg = $latest.Message.Substring(0, [Math]::Min(80, $latest.Message.Length))
            Record-Fail "event log" "$($events.Count) error(s) in last 24h — latest: $msg"
        } else { Record-Pass "event log clean" "0 errors in last 24h" }
    } catch {
        if ($_.Exception.Message -match "No events were found") {
            Record-Pass "event log clean" "0 errors in last 24h"
        } elseif ($_.Exception.Message -match "not found|does not exist") {
            Record-Skip "event log" "DNS Server event log not available"
        } else { Record-Fail "event log" $_.Exception.Message }
    }
}

# ============================================================================
# OUTPUT
# ============================================================================

function Write-Summary {
    $duration = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds)
    Write-Host ""
    $sep = [string]::new([char]0x2500, 40)
    Write-Color $sep "White"
    Write-Color "Summary  $DnsServer" "White"
    Write-Host "  $($script:Pass) passed  $($script:Fail) failed  $($script:Skip) skipped  (${duration}s)"
    Write-Color $sep "White"
    if ($script:Fail -eq 0) { Write-Color "All tests passed." "Green" }
    else { Write-Color "$($script:Fail) test(s) failed." "Red" }
}

function Write-TapHeader { Write-Host "TAP version 13" }

function Write-TapFooter {
    Write-Host "1..$($script:Total)"
    Write-Host "# pass $($script:Pass)"
    Write-Host "# fail $($script:Fail)"
    Write-Host "# skip $($script:Skip)"
}

# ============================================================================
# HELP
# ============================================================================

function Show-Usage {
    @"
Usage: .\windows-dns-smoke-tests.ps1 [OPTIONS]

Smoke-test a Windows DNS Server. PowerShell 5.1+, DnsServer module only.

Parameters:
  -DnsServer SERVER   DNS server to test (default: localhost)
  -Domain DOMAIN      AD domain name (default: auto-detect from environment)
  -TestDomain DOMAIN  Domain for resolution tests (default: example.com)
  -OutputFormat FMT   Output format: text (default), tap
  -NoColor            Disable colored output
  -Help               Show this help message

Examples:
  .\windows-dns-smoke-tests.ps1
  .\windows-dns-smoke-tests.ps1 -DnsServer 10.0.0.53 -Domain corp.local
  .\windows-dns-smoke-tests.ps1 -OutputFormat tap
  .\windows-dns-smoke-tests.ps1 -NoColor | Out-File dns-results.txt

Requirements:
  - PowerShell 5.1+ or PowerShell 7+
  - DnsServer PowerShell module (DNS Server role or RSAT)
  - Administrator privileges for statistics and event log access
"@
}

# ============================================================================
# MAIN
# ============================================================================

if ($Help) { Show-Usage; exit 0 }

$script:StartTime = Get-Date

if ($OutputFormat -eq "tap") {
    Write-TapHeader
} else {
    Write-Host ""
    Write-Color "Windows DNS Smoke Tests" "White"
    Write-Host "Server: $DnsServer"
    if ($Domain) { Write-Host "Domain: $Domain" }
    Write-Host "Time:   $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')"
}

try { Import-Module DnsServer -ErrorAction Stop } catch {
    Write-Color "[ERROR] DnsServer module not available. Install the DNS Server role or RSAT DNS tools." "Red"
    exit 1
}

Test-DnsService
Test-DnsResponds
Test-ZonesLoaded
Test-ADIntegratedZones
Test-ForwardLookup
Test-ReverseLookup
Test-ConditionalForwarders
Test-RootHints
Test-Forwarders
Test-Scavenging
Test-ZoneDelegation
Test-CacheStats
Test-EventLog

if ($OutputFormat -eq "tap") { Write-TapFooter } else { Write-Summary }
if ($script:Fail -eq 0) { exit 0 } else { exit 1 }
