###############################################################################
#### windows-exchange-smoke-tests.ps1 — Verify Exchange Server health      ####
#### Checks Exchange services, OWA, ECP, Autodiscover, ActiveSync, EWS,    ####
#### MAPI/HTTP, mail flow, queues, DAG, databases, certs, and event logs.   ####
####                                                                       ####
#### Author:  Phil Connor                                                  ####
#### Contact: contact@mylinux.work                                         ####
#### License: MIT                                                          ####
#### Version: 1.0                                                          ####
####                                                                       ####
#### Usage:   .\windows-exchange-smoke-tests.ps1                           ####
####          .\windows-exchange-smoke-tests.ps1 -ExchangeServer mail01    ####
####          .\windows-exchange-smoke-tests.ps1 -OutputFormat tap         ####
####                                                                       ####
#### See -Help for all options.                                            ####
###############################################################################

[CmdletBinding()]
param(
    [string]$ExchangeServer = "localhost",
    [int]$CertExpiryDays = 30,
    [int]$MaxQueueLength = 100,
    [ValidateSet("text","tap")]
    [string]$OutputFormat = "text",
    [switch]$NoColor,
    [switch]$Help
)

$ErrorActionPreference = "Continue"

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

if ($Help) {
    @"
Usage: .\windows-exchange-smoke-tests.ps1 [OPTIONS]

Smoke-test Exchange Server infrastructure. PowerShell 5.1+.
Designed for Exchange servers or machines with Exchange Management Shell.

Parameters:
  -ExchangeServer HOST   Target Exchange server hostname or IP (default: localhost)
  -CertExpiryDays N      Certificate expiry warning threshold in days (default: 30)
  -MaxQueueLength N      Max messages per queue before failure (default: 100)
  -OutputFormat FORMAT   Output: text (default), tap
  -NoColor               Disable coloured output
  -Verbose               Show debug output
  -Help                  Show this help

Examples:
  .\windows-exchange-smoke-tests.ps1
  .\windows-exchange-smoke-tests.ps1 -ExchangeServer mail01.corp.local
  .\windows-exchange-smoke-tests.ps1 -CertExpiryDays 60 -MaxQueueLength 50
  .\windows-exchange-smoke-tests.ps1 -OutputFormat tap -NoColor
"@
    exit 0
}

# ============================================================================
# STATE
# ============================================================================

$script:Pass = 0
$script:Fail = 0
$script:Skip = 0
$script:Total = 0
$script:Results = @()
$script:StartTime = Get-Date
$script:EMSLoaded = $false

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

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

function Write-Log   { param([string]$Msg) Write-Color "[INFO] $Msg" "Cyan" }
function Write-Warn  { param([string]$Msg) Write-Color "[WARN] $Msg" "Yellow" }
function Write-Err   { param([string]$Msg) Write-Color "[ERROR] $Msg" "Red" }

# ============================================================================
# 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$(if($Detail){" ($Detail)"})"
    } else {
        $mark = if ($NoColor) { "[PASS]" } else { [char]0x2713 }
        $msg = "  $mark $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 {
        $mark = if ($NoColor) { "[FAIL]" } else { [char]0x2717 }
        $msg = "  $mark $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 {
        $mark = if ($NoColor) { "[SKIP]" } else { [char]0x2298 }
        $msg = "  $mark $Name"
        if ($Reason) { $msg += " - $Reason" }
        Write-Color $msg "Yellow"
    }
}

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

function Test-CommandExists {
    param([string]$Command)
    $null -ne (Get-Command $Command -ErrorAction SilentlyContinue)
}

function Write-Section {
    param([string]$Name)
    if ($OutputFormat -eq "text") {
        Write-Host ""
        Write-Color $Name "White"
    }
}

function Test-HttpEndpoint {
    param([string]$Url, [string]$Name)

    try {
        # Ignore certificate errors for self-signed Exchange certs
        try {
            [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
        } catch {}

        $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop
        Record-Pass $Name "$Url - HTTP $($response.StatusCode)"
    } catch {
        $ex = $_.Exception
        if ($ex.Response) {
            $statusCode = [int]$ex.Response.StatusCode
            # 401/403 means the endpoint is alive but requires auth — that's fine
            if ($statusCode -eq 401 -or $statusCode -eq 403) {
                Record-Pass $Name "$Url - HTTP $statusCode (auth required)"
            } else {
                Record-Fail $Name "$Url - HTTP $statusCode"
            }
        } else {
            Record-Fail $Name "$Url - $($ex.Message)"
        }
    } finally {
        try {
            [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $null
        } catch {}
    }
}

# ============================================================================
# EXCHANGE MANAGEMENT SHELL
# ============================================================================

function Initialize-ExchangeShell {
    # Check if Exchange cmdlets are already available
    if (Test-CommandExists "Get-ExchangeServer") {
        $script:EMSLoaded = $true
        Write-Verbose "Exchange Management Shell already loaded"
        return
    }

    # Try loading the Exchange snap-in
    try {
        Add-PSSnapin Microsoft.Exchange.Management.PowerShell.SnapIn -ErrorAction Stop
        $script:EMSLoaded = $true
        Write-Verbose "Exchange Management Shell loaded via snap-in"
        return
    } catch {
        Write-Verbose "Snap-in load failed: $($_.Exception.Message)"
    }

    # Try the Exchange module path
    $exchangeShell = "$env:ExchangeInstallPath\bin\RemoteExchange.ps1"
    if ($exchangeShell -and (Test-Path $exchangeShell -ErrorAction SilentlyContinue)) {
        try {
            . $exchangeShell
            Connect-ExchangeServer -auto -ClientApplication:ManagementShell
            $script:EMSLoaded = $true
            Write-Verbose "Exchange Management Shell loaded via RemoteExchange.ps1"
            return
        } catch {
            Write-Verbose "RemoteExchange.ps1 failed: $($_.Exception.Message)"
        }
    }

    Write-Verbose "Exchange Management Shell not available — falling back to connectivity tests"
}

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

# -- 1. Exchange Core Services -----------------------------------------------

function Test-ExchangeServices {
    Write-Section "Services"

    $services = @(
        "MSExchangeADTopology",
        "MSExchangeIS",
        "MSExchangeTransport",
        "MSExchangeServiceHost",
        "MSExchangeMailboxAssistants",
        "MSExchangeDelivery",
        "MSExchangeSubmission",
        "MSExchangeRPC",
        "MSExchangeFastSearch",
        "MSExchangeRepl"
    )

    $running = 0
    $stopped = 0
    $missing = 0
    $stoppedNames = @()

    foreach ($svc in $services) {
        try {
            $service = Get-Service -Name $svc -ComputerName $ExchangeServer -ErrorAction Stop
            if ($service.Status -eq "Running") {
                $running++
            } else {
                $stopped++
                $stoppedNames += "$svc ($($service.Status))"
            }
        } catch {
            $missing++
            Write-Verbose "Service $svc not found on $ExchangeServer"
        }
    }

    if ($missing -eq $services.Count) {
        Record-Skip "Exchange services running" "no Exchange services found on $ExchangeServer"
    } elseif ($stopped -gt 0) {
        Record-Fail "Exchange services running" "$stopped stopped: $($stoppedNames -join ', ')"
    } else {
        Record-Pass "Exchange services running" "$running of $($services.Count) services running"
    }
}

# -- 2. Exchange Management Shell -------------------------------------------

function Test-ExchangeManagementShell {
    if ($script:EMSLoaded) {
        try {
            $server = Get-ExchangeServer $ExchangeServer -ErrorAction Stop
            Record-Pass "Exchange Management Shell" "loaded — $($server.Name) ($($server.ServerRole))"
        } catch {
            Record-Pass "Exchange Management Shell" "loaded"
        }
    } else {
        Record-Skip "Exchange Management Shell" "snap-in not available"
    }
}

# -- 3. OWA -----------------------------------------------------------------

function Test-OWA {
    Write-Section "Client Access Endpoints"

    $url = "https://$ExchangeServer/owa/"
    Test-HttpEndpoint -Url $url -Name "OWA responding"
}

# -- 4. ECP -----------------------------------------------------------------

function Test-ECP {
    $url = "https://$ExchangeServer/ecp/"
    Test-HttpEndpoint -Url $url -Name "ECP responding"
}

# -- 5. Autodiscover ---------------------------------------------------------

function Test-Autodiscover {
    $url = "https://$ExchangeServer/Autodiscover/Autodiscover.xml"
    Test-HttpEndpoint -Url $url -Name "Autodiscover endpoint"
}

# -- 6. ActiveSync -----------------------------------------------------------

function Test-ActiveSync {
    $url = "https://$ExchangeServer/Microsoft-Server-ActiveSync"
    Test-HttpEndpoint -Url $url -Name "ActiveSync endpoint"
}

# -- 7. EWS ------------------------------------------------------------------

function Test-EWS {
    $url = "https://$ExchangeServer/EWS/Exchange.asmx"
    Test-HttpEndpoint -Url $url -Name "EWS endpoint"
}

# -- 8. MAPI/HTTP ------------------------------------------------------------

function Test-MAPIHTTP {
    $url = "https://$ExchangeServer/mapi/"
    Test-HttpEndpoint -Url $url -Name "MAPI/HTTP endpoint"
}

# -- 9. Mail Flow (Transport Queues) -----------------------------------------

function Test-MailFlow {
    Write-Section "Mail Flow"

    if (-not $script:EMSLoaded) {
        Record-Skip "Mail flow" "Exchange Management Shell not loaded"
        return
    }

    try {
        $queues = Get-Queue -Server $ExchangeServer -ErrorAction Stop
        if (-not $queues) {
            Record-Pass "Mail flow" "no queues returned (transport idle)"
            return
        }

        $deferred = $queues | Where-Object { $_.Status -eq "Retry" -or $_.DeliveryType -eq "Unreachable" }
        if ($deferred) {
            $count = ($deferred | Measure-Object).Count
            Record-Fail "Mail flow" "$count queue(s) in retry or unreachable state"
        } else {
            $queueCount = ($queues | Measure-Object).Count
            Record-Pass "Mail flow" "$queueCount queue(s), none in retry/unreachable"
        }
    } catch {
        Record-Fail "Mail flow" "Get-Queue error - $($_.Exception.Message)"
    }
}

# -- 10. Mail Queue Length ---------------------------------------------------

function Test-MailQueueLength {
    if (-not $script:EMSLoaded) {
        Record-Skip "Mail queue length" "Exchange Management Shell not loaded"
        return
    }

    try {
        $queues = Get-Queue -Server $ExchangeServer -ErrorAction Stop
        if (-not $queues) {
            Record-Pass "Mail queue length" "no queues (transport idle)"
            return
        }

        $overThreshold = $queues | Where-Object { $_.MessageCount -gt $MaxQueueLength }
        if ($overThreshold) {
            $worst = ($overThreshold | Sort-Object MessageCount -Descending | Select-Object -First 1)
            Record-Fail "Mail queue length" "$($worst.Identity) has $($worst.MessageCount) messages (threshold $MaxQueueLength)"
        } else {
            $totalMessages = ($queues | Measure-Object -Property MessageCount -Sum).Sum
            Record-Pass "Mail queue length" "$totalMessages total messages, threshold $MaxQueueLength"
        }
    } catch {
        Record-Fail "Mail queue length" "Get-Queue error - $($_.Exception.Message)"
    }
}

# -- 11. DAG Health ----------------------------------------------------------

function Test-DAGHealth {
    Write-Section "Database Availability Group"

    if (-not $script:EMSLoaded) {
        Record-Skip "DAG health" "Exchange Management Shell not loaded"
        return
    }

    try {
        $server = Get-ExchangeServer $ExchangeServer -ErrorAction Stop
        $dagMembership = Get-DatabaseAvailabilityGroup -ErrorAction Stop | Where-Object {
            $_.Servers -contains $server.Name -or $_.Servers -contains $server.Identity
        }

        if (-not $dagMembership) {
            Record-Skip "DAG health" "not a DAG member"
            return
        }

        $dagName = $dagMembership.Name
        $memberCount = ($dagMembership.Servers | Measure-Object).Count

        # Check replication health
        try {
            $replHealth = Test-ReplicationHealth -Server $ExchangeServer -ErrorAction Stop
            $failures = $replHealth | Where-Object { $_.Result -ne "Passed" }
            if ($failures) {
                $failCount = ($failures | Measure-Object).Count
                $failNames = ($failures | Select-Object -ExpandProperty Check) -join ", "
                Record-Fail "DAG health" "$dagName - $failCount check(s) failed: $failNames"
            } else {
                Record-Pass "DAG health" "$dagName, $memberCount members, all checks passed"
            }
        } catch {
            Record-Pass "DAG health" "$dagName, $memberCount members"
        }
    } catch {
        Record-Fail "DAG health" "error - $($_.Exception.Message)"
    }
}

# -- 12. Database Copy Status ------------------------------------------------

function Test-DatabaseCopyStatus {
    if (-not $script:EMSLoaded) {
        Record-Skip "Database copy status" "Exchange Management Shell not loaded"
        return
    }

    try {
        $copies = Get-MailboxDatabaseCopyStatus -Server $ExchangeServer -ErrorAction Stop
        if (-not $copies) {
            Record-Skip "Database copy status" "no database copies found"
            return
        }

        $unhealthy = $copies | Where-Object {
            $_.Status -notin @("Healthy", "Mounted")
        }

        if ($unhealthy) {
            $count = ($unhealthy | Measure-Object).Count
            $details = ($unhealthy | ForEach-Object { "$($_.DatabaseName): $($_.Status)" }) -join ", "
            Record-Fail "Database copy status" "$count unhealthy: $details"
        } else {
            $copyCount = ($copies | Measure-Object).Count
            Record-Pass "Database copy status" "$copyCount copies, all healthy/mounted"
        }
    } catch {
        Record-Fail "Database copy status" "error - $($_.Exception.Message)"
    }
}

# -- 13. Database Mount Status -----------------------------------------------

function Test-DatabaseMountStatus {
    Write-Section "Databases"

    if (-not $script:EMSLoaded) {
        Record-Skip "Database mount status" "Exchange Management Shell not loaded"
        return
    }

    try {
        $databases = Get-MailboxDatabase -Server $ExchangeServer -Status -ErrorAction Stop
        if (-not $databases) {
            Record-Skip "Database mount status" "no databases found on $ExchangeServer"
            return
        }

        $dismounted = $databases | Where-Object { -not $_.Mounted }
        if ($dismounted) {
            $count = ($dismounted | Measure-Object).Count
            $names = ($dismounted | Select-Object -ExpandProperty Name) -join ", "
            Record-Fail "Database mount status" "$count dismounted: $names"
        } else {
            $dbCount = ($databases | Measure-Object).Count
            Record-Pass "Database mount status" "$dbCount database(s), all mounted"
        }
    } catch {
        Record-Fail "Database mount status" "error - $($_.Exception.Message)"
    }
}

# -- 14. Certificate Status --------------------------------------------------

function Test-CertificateStatus {
    Write-Section "Certificates"

    if (-not $script:EMSLoaded) {
        Record-Skip "Certificate expiry" "Exchange Management Shell not loaded"
        return
    }

    try {
        $certs = Get-ExchangeCertificate -Server $ExchangeServer -ErrorAction Stop
        if (-not $certs) {
            Record-Skip "Certificate expiry" "no Exchange certificates found"
            return
        }

        $now = Get-Date
        $threshold = $now.AddDays($CertExpiryDays)
        $expired = @()
        $expiring = @()

        foreach ($cert in $certs) {
            if ($cert.NotAfter -lt $now) {
                $expired += $cert
            } elseif ($cert.NotAfter -lt $threshold) {
                $expiring += $cert
            }
        }

        if ($expired.Count -gt 0) {
            $names = ($expired | ForEach-Object { "$($_.Subject) (expired $($_.NotAfter.ToString('yyyy-MM-dd')))" }) -join ", "
            Record-Fail "Certificate expiry" "$($expired.Count) expired: $names"
        } elseif ($expiring.Count -gt 0) {
            $nearest = ($expiring | Sort-Object NotAfter | Select-Object -First 1)
            $daysLeft = [math]::Floor(($nearest.NotAfter - $now).TotalDays)
            Record-Fail "Certificate expiry" "$($expiring.Count) expiring within $CertExpiryDays days (nearest: $daysLeft days)"
        } else {
            $certCount = ($certs | Measure-Object).Count
            $nearest = ($certs | Sort-Object NotAfter | Select-Object -First 1)
            $daysLeft = [math]::Floor(($nearest.NotAfter - $now).TotalDays)
            Record-Pass "Certificate expiry" "$certCount cert(s), nearest expires in $daysLeft days"
        }
    } catch {
        Record-Fail "Certificate expiry" "error - $($_.Exception.Message)"
    }
}

# -- 15. Outlook Anywhere ---------------------------------------------------

function Test-OutlookAnywhere {
    Write-Section "Outlook Anywhere"

    if (-not $script:EMSLoaded) {
        Record-Skip "Outlook Anywhere" "Exchange Management Shell not loaded"
        return
    }

    try {
        $oa = Get-OutlookAnywhere -Server $ExchangeServer -ErrorAction Stop
        if (-not $oa) {
            Record-Skip "Outlook Anywhere" "not configured on $ExchangeServer"
            return
        }

        $external = $oa.ExternalHostname
        $authMethod = $oa.ExternalClientAuthenticationMethod
        if ($external) {
            Record-Pass "Outlook Anywhere" "external: $external ($authMethod)"
        } else {
            Record-Pass "Outlook Anywhere" "configured (no external hostname)"
        }
    } catch {
        Record-Fail "Outlook Anywhere" "error - $($_.Exception.Message)"
    }
}

# -- 16. Exchange Event Log --------------------------------------------------

function Test-EventLog {
    Write-Section "Event Log"

    try {
        $since = (Get-Date).AddHours(-24)
        $events = Get-WinEvent -FilterHashtable @{
            LogName      = "Application"
            ProviderName = "MSExchange*"
            Level        = 2  # Error
            StartTime    = $since
        } -ComputerName $ExchangeServer -MaxEvents 50 -ErrorAction Stop

        if ($events) {
            $count = ($events | Measure-Object).Count
            $sources = ($events | Group-Object ProviderName | Sort-Object Count -Descending |
                Select-Object -First 3 | ForEach-Object { "$($_.Name) ($($_.Count))" }) -join ", "
            Record-Fail "Exchange event log" "$count error(s) in last 24h: $sources"
        } else {
            Record-Pass "Exchange event log" "no errors in last 24h"
        }
    } catch {
        if ($_.Exception.Message -match "No events were found") {
            Record-Pass "Exchange event log" "no errors in last 24h"
        } else {
            Record-Skip "Exchange event log" "cannot read event log - $($_.Exception.Message)"
        }
    }
}

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

function Write-Header {
    if ($OutputFormat -eq "tap") {
        Write-Host "TAP version 13"
    } else {
        Write-Host ""
        Write-Color "Windows Exchange Smoke Tests" "White"
        Write-Host "Server: $ExchangeServer"
        Write-Host "EMS:    $(if ($script:EMSLoaded) { 'loaded' } else { 'not available' })"
        Write-Host "Time:   $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')"
    }
}

function Write-Summary {
    $duration = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds)

    if ($OutputFormat -eq "tap") {
        Write-Host "1..$($script:Total)"
        Write-Host "# pass $($script:Pass)"
        Write-Host "# fail $($script:Fail)"
        Write-Host "# skip $($script:Skip)"
    } else {
        Write-Host ""
        $separator = [string]::new([char]0x2500, 40)
        Write-Color $separator "White"
        Write-Color "Summary  $ExchangeServer" "White"

        $summaryLine = "  $($script:Pass) passed  $($script:Fail) failed  $($script:Skip) skipped  (${duration}s)"
        Write-Host $summaryLine
        Write-Color $separator "White"

        if ($script:Fail -eq 0) {
            Write-Color "All tests passed." "Green"
        } else {
            Write-Color "$($script:Fail) test(s) failed." "Red"
        }
    }
}

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

# Load Exchange Management Shell if available
Initialize-ExchangeShell

Write-Header

# Run all tests
Test-ExchangeServices
Test-ExchangeManagementShell
Test-OWA
Test-ECP
Test-Autodiscover
Test-ActiveSync
Test-EWS
Test-MAPIHTTP
Test-MailFlow
Test-MailQueueLength
Test-DAGHealth
Test-DatabaseCopyStatus
Test-DatabaseMountStatus
Test-CertificateStatus
Test-OutlookAnywhere
Test-EventLog

Write-Summary

if ($script:Fail -eq 0) { exit 0 } else { exit 1 }
