NetDiagram-PS.psm1

#Requires -Version 7.4

<#
.SYNOPSIS
    NetDiagram-PS - Network discovery and diagram generation module
.DESCRIPTION
    Discovers network nodes/links and emits valid .drawio files with SNMP support
#>


Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Module-level variables
$script:ModuleVersion = '1.3.0'

#region Helper Functions

function Get-LayerFromRole {
    <#
    .SYNOPSIS
        Maps device role to network layer for diagram placement
    #>

    param([string]$Role)

    switch ($Role) {
        'core-router' { return 'Core' }
        'router'      { return 'Core' }
        'distribution'{ return 'Dist' }
        'switch'      { return 'Access' }
        'server'      { return 'Servers' }
        default       { return 'Access' }
    }
}

function New-EmptyTopology {
    <#
    .SYNOPSIS
        Creates an empty topology object
    #>

    [pscustomobject]@{
        Nodes   = @()
        Edges   = @()
        Subnets = @()
    }
}

#endregion

#region Import-Inventory

function Import-Inventory {
    <#
    .SYNOPSIS
        Imports network inventory from JSON file
    .DESCRIPTION
        Reads a JSON inventory file and creates a Topology object with Nodes and Subnets.
        Nodes are enriched with Layer information based on their role.
    .PARAMETER Path
        Path to the inventory JSON file
    .EXAMPLE
        $topo = Import-Inventory -Path '.\examples\inventory-template.json'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path
    )

    process {
        if (-not (Test-Path -Path $Path -PathType Leaf)) {
            throw "Inventory file not found: $Path"
        }

        try {
            $inventory = Get-Content -Path $Path -Raw | ConvertFrom-Json
        }
        catch {
            throw "Failed to parse inventory JSON: $_"
        }

        if (-not $inventory.PSObject.Properties['knownDevices']) {
            throw "Invalid inventory: missing 'knownDevices' property"
        }

        $topology = New-EmptyTopology

        # Process known devices
        $nodes = foreach ($device in $inventory.knownDevices) {
            if ([string]::IsNullOrWhiteSpace($device.ip)) {
                Write-Warning "Skipping device without IP address"
                continue
            }

            $role = if ($device.PSObject.Properties['role']) { $device.role } else { 'unknown' }
            $layer = Get-LayerFromRole -Role $role

            [pscustomobject]@{
                IP        = $device.ip
                Hostname  = if ($device.PSObject.Properties['hostname']) { $device.hostname } else { $device.ip }
                Role      = $role
                Vendor    = if ($device.PSObject.Properties['vendor']) { $device.vendor } else { 'Unknown' }
                OS        = if ($device.PSObject.Properties['os']) { $device.os } else { 'Unknown' }
                Layer     = $layer
                Reachable = $null
            }
        }

        $topology.Nodes = @($nodes)

        # Process subnets if present
        if ($inventory.PSObject.Properties['subnets'] -and $inventory.subnets) {
            $subnets = foreach ($subnet in $inventory.subnets) {
                if ([string]::IsNullOrWhiteSpace($subnet.cidr)) {
                    Write-Warning "Skipping subnet without CIDR"
                    continue
                }

                [pscustomobject]@{
                    CIDR  = $subnet.cidr
                    Label = if ($subnet.PSObject.Properties['label']) { $subnet.label } else { $subnet.cidr }
                    VLAN  = if ($subnet.PSObject.Properties['vlan']) { $subnet.vlan } else { $null }
                }
            }
            $topology.Subnets = @($subnets)
        }

        Write-Verbose "Imported $($topology.Nodes.Count) nodes and $($topology.Subnets.Count) subnets"

        return $topology
    }
}

#endregion

#region Test-DeviceReachability

function Test-DeviceReachability {
    <#
    .SYNOPSIS
        Tests network reachability for all nodes in a topology
    .DESCRIPTION
        Uses Test-Connection with parallel processing to quickly test device reachability.
        Updates the Reachable property on each node.
    .PARAMETER Topology
        Topology object from Import-Inventory
    .PARAMETER MaxParallel
        Maximum number of parallel ping tests (default: 32)
    .PARAMETER TimeoutSeconds
        Timeout for each ping test (default: 1)
    .EXAMPLE
        $topo = Import-Inventory -Path '.\my-inventory.json' | Test-DeviceReachability -MaxParallel 64
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject]$Topology,

        [Parameter()]
        [int]$MaxParallel = 32,

        [Parameter()]
        [int]$TimeoutSeconds = 1
    )

    process {
        if ($null -eq $Topology -or $null -eq $Topology.Nodes) {
            throw "Invalid topology object"
        }

        if ($Topology.Nodes.Count -eq 0) {
            Write-Warning "No nodes to test"
            return $Topology
        }

        Write-Verbose "Testing reachability for $($Topology.Nodes.Count) nodes with $MaxParallel parallel threads"

        # Create a synchronized hashtable for results
        $results = [System.Collections.Concurrent.ConcurrentDictionary[string,bool]]::new()

        # Test reachability in parallel
        $Topology.Nodes | ForEach-Object -Parallel {
            $node = $_
            $timeout = $using:TimeoutSeconds
            $resultsDict = $using:results

            try {
                $pingResult = Test-Connection -ComputerName $node.IP -Count 1 -TimeoutSeconds $timeout -ErrorAction SilentlyContinue -Quiet
                $null = $resultsDict.TryAdd($node.IP, $pingResult)
            }
            catch {
                $null = $resultsDict.TryAdd($node.IP, $false)
            }
        } -ThrottleLimit $MaxParallel

        # Update nodes with results
        foreach ($node in $Topology.Nodes) {
            $reachable = $false
            if ($results.TryGetValue($node.IP, [ref]$reachable)) {
                $node.Reachable = $reachable
            }
            else {
                $node.Reachable = $false
            }
        }

        $reachableCount = @($Topology.Nodes | Where-Object { $_.Reachable -eq $true }).Count
        Write-Verbose "Reachability test complete: $reachableCount/$($Topology.Nodes.Count) nodes reachable"

        return $Topology
    }
}

#endregion

#region SNMP Functions

function Invoke-SnmpWalk {
    <#
    .SYNOPSIS
        Invokes the net-snmp snmpwalk binary against a target device
    .DESCRIPTION
        Calls snmpwalk directly (must be in PATH) with specified OID and credentials.
        Resolves 'snmpwalk' on macOS/Linux and 'snmpwalk.exe' on Windows.
        Returns raw output lines.

        SECURITY - PROCESS LIST EXPOSURE (known limitation): snmpwalk receives the
        community string as a command-line argument ('-c <community>'). While it runs,
        the community is visible to any local user who can list processes (ps, Get-Process
        with command-line access, /proc). This is inherent to shelling out to net-snmp and
        cannot be avoided without a native SNMP client. Verbose logging in this function
        redacts the community (prints '-c ****'), but the process arguments themselves are
        not redacted. Treat SNMP v1/v2c community strings as low-secrecy and prefer SNMPv3
        with authentication for anything sensitive.
    .PARAMETER TargetIP
        IP address or hostname (FQDN) to query. Must not begin with '-'.
    .PARAMETER Community
        SNMP community string. Must not begin with '-' (argument-injection guard).
    .PARAMETER OID
        OID to walk (default: LLDP remote table). Digits and dots only.
    .PARAMETER Version
        SNMP version: v1, v2c, or v3 (default: v2c)
    .PARAMETER TimeoutSeconds
        Timeout in seconds (default: 2)
    .EXAMPLE
        Invoke-SnmpWalk -TargetIP '10.66.1.1' -Community 'public'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateScript({
            if ($_ -match '^-') { throw "TargetIP must not begin with '-'." }
            if ($_ -notmatch '^(?:\d{1,3}(?:\.\d{1,3}){3}|[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*)$') {
                throw "TargetIP '$_' is not a valid IPv4 address or hostname."
            }
            $true
        })]
        [string]$TargetIP,

        [Parameter(Mandatory)]
        [ValidateScript({
            if ($_ -match '^-') { throw "Community must not begin with '-' (argument-injection guard)." }
            $true
        })]
        [string]$Community,

        [Parameter()]
        [ValidatePattern('^\d+(\.\d+)*$')]
        [string]$OID = '1.0.8802.1.1.2.1.4',

        [Parameter()]
        [ValidateSet('v1', 'v2c', 'v3')]
        [string]$Version = 'v2c',

        [Parameter()]
        [ValidateRange(1, 300)]
        [int]$TimeoutSeconds = 2
    )

    # Locate the snmpwalk binary. It is 'snmpwalk.exe' on Windows and 'snmpwalk'
    # on macOS/Linux, so probe for both rather than assuming the Windows name.
    # Restrict to Application so a same-named function/alias/script cannot be invoked.
    $snmpWalkCmd = $null
    foreach ($candidate in @('snmpwalk', 'snmpwalk.exe')) {
        $found = Get-Command -Name $candidate -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($found) {
            $snmpWalkCmd = $found.Source
            break
        }
    }
    if (-not $snmpWalkCmd) {
        throw "snmpwalk not found in PATH. Please install net-snmp tools (brew install net-snmp, apt install snmp, or choco install net-snmp)."
    }

    try {
        $arguments = @(
            '-' + $Version.ToLower()
            '-c', $Community
            '-t', $TimeoutSeconds.ToString()
            $TargetIP
            $OID
        )

        # Never emit the community string in verbose output. Redact '-c <community>'
        # to '-c ****'. (The community is still visible in the process argument list
        # while snmpwalk runs - see the SECURITY note in the function help.)
        $redactedArgs = @(
            '-' + $Version.ToLower()
            '-c', '****'
            '-t', $TimeoutSeconds.ToString()
            $TargetIP
            $OID
        )
        Write-Verbose "Running: $snmpWalkCmd $($redactedArgs -join ' ')"

        $output = & $snmpWalkCmd @arguments 2>&1

        if ($LASTEXITCODE -ne 0) {
            Write-Warning "snmpwalk failed for $TargetIP with exit code $LASTEXITCODE"
            return @()
        }

        return $output
    }
    catch {
        Write-Warning "Failed to execute snmpwalk for ${TargetIP}: $_"
        return @()
    }
}


function Get-SnmpNeighbors {
    <#
    .SYNOPSIS
        Discovers neighbors via SNMP LLDP/CDP (best-effort MVP)
    .DESCRIPTION
        Queries nodes for LLDP neighbor information via SNMP.
        Uses credential map to resolve community strings from SecretManagement.
        Adds discovered edges to the topology with L2-SNMP confidence.

        NODE ELIGIBILITY: By default every node is queried EXCEPT nodes that have been
        explicitly marked unreachable (Reachable -eq $false). Nodes with unknown
        reachability (Reachable -eq $null, e.g. straight after Import-Inventory) ARE
        queried, so the documented "import inventory then discover via SNMP" workflow
        works without first running Test-DeviceReachability. Pass -OnlyReachable to
        restrict querying to nodes proven reachable (Reachable -eq $true).

        PARSING IS BEST-EFFORT (MVP): the neighbor parser scans snmpwalk output for the
        first IPv4-looking token on each line and only creates an edge when that address
        already exists as a node in the topology. It does NOT fully decode the LLDP/CDP
        MIB rows (chassis-id / management-address / port-id sub-OIDs are not joined). It
        can miss neighbors and, on unusual output, misattribute a link. Treat the SNMP
        edges as hints to verify, not authoritative topology.
    .PARAMETER Topology
        Topology object with nodes
    .PARAMETER CredentialMapPath
        Path to credential map JSON file
    .PARAMETER OID
        SNMP OID to walk (default: LLDP remote table)
    .PARAMETER OnlyReachable
        Only query nodes proven reachable (Reachable -eq $true). Off by default so that
        freshly imported inventory (Reachable = $null) is still queried.
    .PARAMETER TryPublic
        If no credentials found for a node, try the common "public" community string.
        WARNING: This may be logged by security systems. Only use on networks you own.
    .EXAMPLE
        $topo = $topo | Get-SnmpNeighbors -CredentialMapPath '.\examples\credmap.json'
    .EXAMPLE
        $topo = $topo | Get-SnmpNeighbors -CredentialMapPath '.\credmap.json' -TryPublic

        Tries user credentials first, falls back to "public" if none found (with warning)
    .EXAMPLE
        $topo = $topo | Test-DeviceReachability | Get-SnmpNeighbors -CredentialMapPath '.\credmap.json' -OnlyReachable

        Ping-tests first, then queries only the nodes that answered.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject]$Topology,

        [Parameter(Mandatory)]
        [string]$CredentialMapPath,

        [Parameter()]
        [string]$OID = '1.0.8802.1.1.2.1.4',

        [Parameter()]
        [switch]$OnlyReachable,

        [Parameter()]
        [switch]$TryPublic
    )

    process {
        if ($null -eq $Topology -or $null -eq $Topology.Nodes) {
            throw "Invalid topology object"
        }

        if (-not (Test-Path -Path $CredentialMapPath -PathType Leaf)) {
            throw "Credential map file not found: $CredentialMapPath"
        }

        try {
            $credMap = Get-Content -Path $CredentialMapPath -Raw | ConvertFrom-Json
        }
        catch {
            throw "Failed to parse credential map JSON: $_"
        }

        # Show warning if -TryPublic is enabled
        if ($TryPublic) {
            Write-Warning @"
SECURITY WARNING: -TryPublic flag enabled
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
This will attempt to use the default SNMP community string "public"
on devices where no credentials are configured.

⚠️ This may be logged by network security systems
⚠️ Only use on networks you own or have authorization to scan
⚠️ Consider using SNMPv3 with authentication for production

All SNMP attempts will be logged to verbose output.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"@

        }

        # Decide which nodes to query. Default: everything that is not explicitly
        # unreachable (so freshly imported inventory with Reachable = $null is eligible).
        # -OnlyReachable narrows to nodes proven reachable (Reachable -eq $true).
        if ($OnlyReachable) {
            $nodesToQuery = @($Topology.Nodes | Where-Object { $_.Reachable -eq $true })
            $eligibilityDesc = 'reachable'
        }
        else {
            $nodesToQuery = @($Topology.Nodes | Where-Object { $_.Reachable -ne $false })
            $eligibilityDesc = 'eligible (reachable or untested)'
        }

        if ($nodesToQuery.Count -eq 0) {
            Write-Warning "No $eligibilityDesc nodes to query via SNMP"
            return $Topology
        }

        Write-Verbose "Querying SNMP neighbors for $($nodesToQuery.Count) $eligibilityDesc node(s)"

        $discoveredEdges = [System.Collections.ArrayList]::new()
        $publicAttempts = 0

        foreach ($node in $nodesToQuery) {
            # Resolve SNMP community for this node
            $community = $null
            $communitySecret = $null
            $source = 'CredentialMap'

            if ($credMap.PSObject.Properties['snmp'] -and $credMap.snmp) {
                foreach ($cidrEntry in $credMap.snmp.PSObject.Properties) {
                    $cidr = $cidrEntry.Name
                    $config = $cidrEntry.Value

                    # Simple CIDR matching (for MVP, just match Default or exact match)
                    if ($cidr -eq 'Default' -or (Test-IPInSubnet -IP $node.IP -CIDR $cidr)) {
                        if ($config.PSObject.Properties['communitySecret']) {
                            $communitySecret = $config.communitySecret
                            break
                        }
                    }
                }
            }

            # Try to get secret from SecretManagement
            if ($communitySecret) {
                try {
                    $secretModule = Get-Module -Name Microsoft.PowerShell.SecretManagement -ListAvailable -ErrorAction SilentlyContinue
                    if ($secretModule) {
                        Import-Module Microsoft.PowerShell.SecretManagement -ErrorAction SilentlyContinue
                        $communitySecretInfo = Get-Secret -Name $communitySecret -AsPlainText -ErrorAction SilentlyContinue
                        if ($communitySecretInfo) {
                            $community = $communitySecretInfo
                        }
                    }
                }
                catch {
                    Write-Warning "Failed to retrieve secret '$communitySecret' for $($node.IP): $_"
                }
            }

            # Level 3: Try "public" if no credentials found and -TryPublic is enabled
            if ([string]::IsNullOrWhiteSpace($community) -and $TryPublic) {
                $community = 'public'
                $source = 'DefaultPublic'
                $publicAttempts++
                Write-Verbose "[TryPublic] Attempting default 'public' community for $($node.IP)"
            }

            if ([string]::IsNullOrWhiteSpace($community)) {
                Write-Warning "No SNMP community found for $($node.IP), skipping"
                continue
            }

            # Invoke SNMP walk
            Write-Verbose " Querying $($node.IP) with community from $source"
            $output = Invoke-SnmpWalk -TargetIP $node.IP -Community $community -OID $OID -ErrorAction SilentlyContinue

            if ($null -eq $output -or @($output).Count -eq 0) {
                Write-Verbose "No SNMP data returned from $($node.IP)"
                continue
            }

            # Parse LLDP/CDP output. BEST-EFFORT MVP (see function help): we do not fully
            # decode the LLDP MIB rows. For each output line we look only at the VALUE
            # portion (right of '='), never the OID itself (the numeric OID contains
            # dotted-decimal runs that would otherwise be mistaken for IPv4 addresses),
            # extract an octet-validated IPv4 management address, and only create an edge
            # when that address is already a known node.
            foreach ($line in $output) {
                $value = if ($line -match '=\s*(.+)$') { $matches[1] } else { '' }
                if ([string]::IsNullOrWhiteSpace($value)) { continue }

                $targetIP = $null

                # net-snmp renders an LLDP management address either as a dotted quad
                # (sometimes prefixed 'IpAddress:') or as a 4-octet Hex-STRING.
                if ($value -match '(?:IpAddress:\s*)?\b((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})\b') {
                    $targetIP = $matches[1]
                }
                elseif ($value -match 'Hex-STRING:\s*([0-9A-Fa-f]{2})[ :]+([0-9A-Fa-f]{2})[ :]+([0-9A-Fa-f]{2})[ :]+([0-9A-Fa-f]{2})') {
                    $targetIP = @(
                        [Convert]::ToInt32($matches[1], 16)
                        [Convert]::ToInt32($matches[2], 16)
                        [Convert]::ToInt32($matches[3], 16)
                        [Convert]::ToInt32($matches[4], 16)
                    ) -join '.'
                }

                if ([string]::IsNullOrWhiteSpace($targetIP)) { continue }

                # Skip self-loops.
                if ($targetIP -eq $node.IP) { continue }

                # Only accept addresses that map to a node already in the topology.
                $targetNode = $Topology.Nodes | Where-Object { $_.IP -eq $targetIP }
                if (-not $targetNode) { continue }

                # Best-effort interface label extraction.
                $label = 'LLDP'
                if ($value -match '(?:ifName|Interface|Port(?:Id)?)\s*[:=]\s*"?([^"\s]+)"?') {
                    $label = $matches[1]
                }

                $edge = [pscustomobject]@{
                    SourceIP   = $node.IP
                    TargetIP   = $targetIP
                    Label      = $label
                    Source     = 'SNMP'
                    Confidence = 'L2-SNMP'
                }

                $null = $discoveredEdges.Add($edge)
            }
        }

        Write-Verbose "Discovered $($discoveredEdges.Count) SNMP edges"

        if ($TryPublic -and $publicAttempts -gt 0) {
            Write-Warning "Used default 'public' community string on $publicAttempts device(s)"
        }

        # Merge new edges with existing ones
        $allEdges = @($Topology.Edges) + @($discoveredEdges)
        $Topology.Edges = Merge-Edges -Edges $allEdges

        return $Topology
    }
}

function Test-IPInSubnet {
    <#
    .SYNOPSIS
        Simple CIDR matching helper
    #>

    param([string]$IP, [string]$CIDR)

    if ([string]::IsNullOrWhiteSpace($IP) -or [string]::IsNullOrWhiteSpace($CIDR)) {
        return $false
    }

    if ($CIDR -notmatch '^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$') {
        return $false
    }

    $networkAddressString = $matches[1]
    $prefixLength = [int]$matches[2]

    if ($prefixLength -lt 0 -or $prefixLength -gt 32) {
        return $false
    }

    try {
        $ipAddress = [System.Net.IPAddress]::Parse($IP)
        $networkAddress = [System.Net.IPAddress]::Parse($networkAddressString)
    }
    catch {
        return $false
    }

    if ($ipAddress.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork -or
        $networkAddress.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) {
        return $false
    }

    $ipBytes = $ipAddress.GetAddressBytes()
    $networkBytes = $networkAddress.GetAddressBytes()

    if ([System.BitConverter]::IsLittleEndian) {
        [Array]::Reverse($ipBytes)
        [Array]::Reverse($networkBytes)
    }

    $ipValue = [System.BitConverter]::ToUInt32($ipBytes, 0)
    $networkValue = [System.BitConverter]::ToUInt32($networkBytes, 0)

    # Build the prefix mask. Compute in UInt64 to avoid the int overflow that
    # '[uint32]0xFFFFFFFF -shl n' triggers (0xFFFFFFFF parses as int -1), then
    # truncate back to 32 bits.
    $mask = if ($prefixLength -eq 0) {
        [uint32]0
    }
    else {
        [uint32]((([uint64]4294967295) -shl (32 - $prefixLength)) -band [uint64]4294967295)
    }

    return (($ipValue -band $mask) -eq ($networkValue -band $mask))
}

#endregion

#region Merge-Edges

function Merge-Edges {
    <#
    .SYNOPSIS
        Merges and deduplicates edge candidates
    .DESCRIPTION
        Deduplicates edges using sorted endpoints as key.
        Prioritizes L2-SNMP confidence over L3-Inferred.
        Keeps first non-empty label.
    .PARAMETER Edges
        Array of edge objects to merge
    .EXAMPLE
        $merged = Merge-Edges -Edges $allEdges
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [array]$Edges
    )

    if ($null -eq $Edges -or $Edges.Count -eq 0) {
        return @()
    }

    $edgeMap = [ordered]@{}

    foreach ($edge in $Edges) {
        if ($null -eq $edge) {
            continue
        }

        # Null safety checks
        if ([string]::IsNullOrWhiteSpace($edge.SourceIP) -or [string]::IsNullOrWhiteSpace($edge.TargetIP)) {
            Write-Warning "Skipping edge with missing IP addresses"
            continue
        }

        # Create dedupe key using sorted endpoints
        $endpoints = @($edge.SourceIP, $edge.TargetIP) | Sort-Object
        $key = "$($endpoints[0])|$($endpoints[1])"

        if ($edgeMap.Contains($key)) {
            $existing = $edgeMap[$key]

            # Prioritize L2-SNMP confidence
            if ($edge.Source -eq 'SNMP' -and $existing.Source -ne 'SNMP') {
                $existing.Source = 'SNMP'
                $existing.Confidence = 'L2-SNMP'
            }

            # Keep first non-empty label
            if ([string]::IsNullOrWhiteSpace($existing.Label) -and -not [string]::IsNullOrWhiteSpace($edge.Label)) {
                $existing.Label = $edge.Label
            }
        }
        else {
            # Add new edge with confidence
            $confidence = if ($edge.PSObject.Properties['Confidence']) {
                $edge.Confidence
            }
            elseif ($edge.Source -eq 'SNMP') {
                'L2-SNMP'
            }
            else {
                'L3-Inferred'
            }

            $edgeMap[$key] = [pscustomobject]@{
                SourceIP   = $edge.SourceIP
                TargetIP   = $edge.TargetIP
                Label      = if ($edge.PSObject.Properties['Label']) { $edge.Label } else { '' }
                Source     = if ($edge.PSObject.Properties['Source']) { $edge.Source } else { 'Unknown' }
                Confidence = $confidence
            }
        }
    }

    return @($edgeMap.Values)
}

#endregion

#region Export-DrawIO

function Export-DrawIO {
    <#
    .SYNOPSIS
        Exports topology to .drawio XML format
    .DESCRIPTION
        Generates a valid draw.io diagram file with hierarchical node placement.
        Nodes are positioned by layer (Core, Dist, Access, Servers) with fixed coordinates.
    .PARAMETER Topology
        Topology object with nodes and edges
    .PARAMETER OutFile
        Output path for .drawio file
    .EXAMPLE
        $topo | Export-DrawIO -OutFile '.\network.drawio'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject]$Topology,

        [Parameter(Mandatory)]
        [string]$OutFile
    )

    process {
        if ($null -eq $Topology -or $null -eq $Topology.Nodes) {
            throw "Invalid topology object"
        }

        # De-duplicate nodes by IP first. Duplicate IPs would otherwise share a single
        # node-ID map entry yet each still be rendered, emitting multiple mxCells with
        # the same id - which is invalid draw.io. Keep the first occurrence and warn.
        $uniqueNodes = [System.Collections.Generic.List[object]]::new()
        $seenIPs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($node in $Topology.Nodes) {
            if ([string]::IsNullOrWhiteSpace($node.IP)) {
                Write-Warning "Skipping node with no IP address"
                continue
            }
            if (-not $seenIPs.Add([string]$node.IP)) {
                Write-Warning "Duplicate IP '$($node.IP)' - keeping first occurrence, skipping duplicate node"
                continue
            }
            $uniqueNodes.Add($node)
        }

        # Build node ID map (one entry per unique IP)
        $nodeIDMap = @{}
        $nextID = 2  # Start after mxCell id="0" and id="1"

        foreach ($node in $uniqueNodes) {
            $nodeIDMap[$node.IP] = $nextID++
        }

        # Layer order for Y-axis positioning
        $layerOrder = @('Core', 'Dist', 'Access', 'Servers')

        # Group nodes by layer
        $nodesByLayer = @{}
        foreach ($layer in $layerOrder) {
            $nodesByLayer[$layer] = @($uniqueNodes | Where-Object { $_.Layer -eq $layer })
        }

        # Start building XML
        $xml = [System.Text.StringBuilder]::new()
        $null = $xml.AppendLine('<?xml version="1.0" encoding="UTF-8"?>')
        $null = $xml.AppendLine('<mxfile host="app.diagrams.net">')
        $null = $xml.AppendLine(' <diagram id="Net" name="Network">')
        $null = $xml.AppendLine(' <mxGraphModel>')
        $null = $xml.AppendLine(' <root>')
        $null = $xml.AppendLine(' <mxCell id="0"/>')
        $null = $xml.AppendLine(' <mxCell id="1" parent="0"/>')

        # Add subnet containers (best practice: group by network segment)
        $containerID = $nextID
        $subnetContainers = @{}

        foreach ($subnet in $Topology.Subnets) {
            $subnetLabel = [System.Security.SecurityElement]::Escape("$($subnet.Label)`n$($subnet.CIDR)")
            $containerStyle = 'swimlane;fontSize=14;fontStyle=1;fillColor=#f5f5f5;strokeColor=#666666;rounded=1;'

            # Calculate container size based on nodes in subnet
            $containerWidth = 800
            $containerHeight = 550
            $containerX = 20 + (($subnetContainers.Count % 2) * 850)
            $containerY = 20 + ([Math]::Floor($subnetContainers.Count / 2) * 600)

            $null = $xml.AppendLine(" <mxCell id=`"$containerID`" value=`"$subnetLabel`" style=`"$containerStyle`" parent=`"1`" vertex=`"1`">")
            $null = $xml.AppendLine(" <mxGeometry x=`"$containerX`" y=`"$containerY`" width=`"$containerWidth`" height=`"$containerHeight`" as=`"geometry`"/>")
            $null = $xml.AppendLine(' </mxCell>')

            $subnetContainers[$subnet.CIDR] = $containerID
            $containerID++
        }

        $nextID = $containerID

        # Map each node to the first subnet container whose CIDR contains it, so nodes
        # are parented/positioned inside the right swimlane instead of always parent="1".
        $nodeContainer = @{}
        foreach ($node in $uniqueNodes) {
            foreach ($subnet in $Topology.Subnets) {
                if ($subnetContainers.ContainsKey($subnet.CIDR) -and (Test-IPInSubnet -IP $node.IP -CIDR $subnet.CIDR)) {
                    $nodeContainer[$node.IP] = $subnetContainers[$subnet.CIDR]
                    break
                }
            }
        }

        # Layout bookkeeping: per-container child index, and a base Y for unmatched
        # (canvas-level) nodes placed below the container grid so nothing overlaps.
        $containerChildCount = @{}
        $containerRows = [Math]::Ceiling($subnetContainers.Count / 2.0)
        $unmatchedBaseY = 20 + ([int]$containerRows * 600) + 40

        # Add nodes with enhanced styles and metadata
        foreach ($layer in $layerOrder) {
            $nodesInLayer = $nodesByLayer[$layer]
            $layerIndex = $layerOrder.IndexOf($layer)

            for ($i = 0; $i -lt $nodesInLayer.Count; $i++) {
                $node = $nodesInLayer[$i]
                $nodeID = $nodeIDMap[$node.IP]

                # Decide parent container and geometry. Nodes matched to a subnet go
                # inside that swimlane (geometry relative to the container); unmatched
                # nodes are laid out on the open canvas by layer, below the containers.
                if ($nodeContainer.ContainsKey($node.IP)) {
                    $parentID = $nodeContainer[$node.IP]
                    $childIndex = if ($containerChildCount.ContainsKey($parentID)) { $containerChildCount[$parentID] } else { 0 }
                    $containerChildCount[$parentID] = $childIndex + 1
                    $col = $childIndex % 4
                    $row = [Math]::Floor($childIndex / 4)
                    $xPos = 20 + ($col * 190)
                    $yPos = 40 + ($row * 110)
                }
                else {
                    $parentID = 1
                    $xPos = 60 + ($i * 190)
                    $yPos = $unmatchedBaseY + ($layerIndex * 130)
                }

                # Get role-specific icon shape and colors
                $shapeConfig = switch ($node.Role) {
                    'core-router'   { @{ shape='mxgraph.cisco.routers.router'; fillColor='#dae8fc'; strokeColor='#6c8ebf' } }
                    'router'        { @{ shape='mxgraph.cisco.routers.router'; fillColor='#dae8fc'; strokeColor='#6c8ebf' } }
                    'distribution'  { @{ shape='mxgraph.cisco.switches.workgroup_switch'; fillColor='#d5e8d4'; strokeColor='#82b366' } }
                    'switch'        { @{ shape='mxgraph.cisco.switches.workgroup_switch'; fillColor='#d5e8d4'; strokeColor='#82b366' } }
                    'server'        { @{ shape='mxgraph.cisco.servers.generic_server'; fillColor='#fff2cc'; strokeColor='#d6b656' } }
                    'workstation'   { @{ shape='mxgraph.cisco.computers_and_peripherals.pc'; fillColor='#e1d5e7'; strokeColor='#9673a6' } }
                    default         { @{ shape='rectangle'; fillColor='#e1e1e1'; strokeColor='#999999' } }
                }

                # Override fill color for reachability status
                if ($node.Reachable -eq $true) {
                    $shapeConfig.fillColor = '#d5e8d4'
                    $shapeConfig.strokeColor = '#82b366'
                } elseif ($node.Reachable -eq $false) {
                    $shapeConfig.fillColor = '#f8cecc'
                    $shapeConfig.strokeColor = '#b85450'
                }

                # Node label with better formatting (escape entire label for XML)
                $labelText = $node.Hostname
                if ($node.IP -ne $node.Hostname) {
                    $labelText += "`n$($node.IP)"
                }
                $label = [System.Security.SecurityElement]::Escape($labelText)

                # Build enhanced style with shadow and rounded corners
                $nodeStyle = "shape=$($shapeConfig.shape);rounded=1;whiteSpace=wrap;html=1;align=center;verticalAlign=top;"
                $nodeStyle += "fillColor=$($shapeConfig.fillColor);strokeColor=$($shapeConfig.strokeColor);"
                $nodeStyle += "shadow=1;fontSize=12;fontFamily=Helvetica;spacingTop=10;"

                # Create tooltip with metadata (best practice: add device details)
                $tooltipParts = @(
                    "IP: $($node.IP)",
                    "Hostname: $($node.Hostname)",
                    "Role: $($node.Role)",
                    "Vendor: $($node.Vendor)",
                    "OS: $($node.OS)",
                    "Layer: $($node.Layer)",
                    "Status: $(if ($node.Reachable) { 'Reachable' } else { 'Unreachable' })"
                )
                $tooltip = [System.Security.SecurityElement]::Escape(($tooltipParts -join "`n"))

                $null = $xml.AppendLine(" <mxCell id=`"$nodeID`" value=`"$label`" style=`"$nodeStyle`" parent=`"$parentID`" vertex=`"1`">")
                $null = $xml.AppendLine(" <mxGeometry x=`"$xPos`" y=`"$yPos`" width=`"140`" height=`"80`" as=`"geometry`"/>")
                $null = $xml.AppendLine(' </mxCell>')

                # Add custom metadata as UserObject (best practice)
                # Note: In production, this would replace the mxCell with UserObject, but keeping it simple for MVP
            }
        }

        # Add edges with enhanced styling (best practice: orthogonal routing, rounded, labeled)
        $edgeID = $nextID
        foreach ($edge in $Topology.Edges) {
            if ($null -eq $edge) {
                continue
            }

            # Validate node IDs exist
            $sourceID = $nodeIDMap[$edge.SourceIP]
            $targetID = $nodeIDMap[$edge.TargetIP]

            if ($null -eq $sourceID -or $null -eq $targetID) {
                Write-Warning "Skipping edge $($edge.SourceIP) -> $($edge.TargetIP): node ID not found"
                continue
            }

            $label = [System.Security.SecurityElement]::Escape($edge.Label)

            # Enhanced edge style with best practices
            $confidence = if ($edge.PSObject.Properties['Confidence']) { $edge.Confidence } else { 'L3-Inferred' }

            # Base style: orthogonal routing with rounded corners (best practice for network diagrams)
            $edgeStyle = 'edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;'
            $edgeStyle += 'labelBackgroundColor=#ffffff;fontSize=11;fontFamily=Helvetica;'

            # Different styling based on confidence level
            if ($confidence -eq 'L2-SNMP') {
                # L2-SNMP: Solid line, green, thicker (verified connection)
                $edgeStyle += 'strokeColor=#2D7600;strokeWidth=2.5;'
                $edgeStyle += 'endArrow=classic;endFill=1;'
            }
            elseif ($confidence -eq 'L3-Inferred') {
                # L3-Inferred: Dashed line, gray (inferred connection)
                $edgeStyle += 'strokeColor=#808080;strokeWidth=1.5;dashed=1;dashPattern=5 5;'
                $edgeStyle += 'endArrow=classic;endFill=0;'
            }
            else {
                # Default: dotted line for unknown
                $edgeStyle += 'strokeColor=#999999;strokeWidth=1;dashed=1;dashPattern=2 2;'
                $edgeStyle += 'endArrow=classic;endFill=0;'
            }

            # Add shadow for depth (best practice)
            $edgeStyle += 'shadow=0;'  # Shadows on edges can be cluttered, keeping it clean

            # Build connector with label
            $null = $xml.AppendLine(" <mxCell id=`"$edgeID`" value=`"$label`" style=`"$edgeStyle`" parent=`"1`" source=`"$sourceID`" target=`"$targetID`" edge=`"1`">")
            $null = $xml.AppendLine(' <mxGeometry relative="1" as="geometry"/>')
            $null = $xml.AppendLine(' </mxCell>')

            $edgeID++
        }

        $null = $xml.AppendLine(' </root>')
        $null = $xml.AppendLine(' </mxGraphModel>')
        $null = $xml.AppendLine(' </diagram>')
        $null = $xml.AppendLine('</mxfile>')

        # Write to file
        try {
            $xml.ToString() | Out-File -FilePath $OutFile -Encoding utf8 -Force
            Write-Verbose "Exported diagram to $OutFile"
        }
        catch {
            throw "Failed to write .drawio file: $_"
        }
    }
}

#endregion

#region Export-Metadata

function Export-Metadata {
    <#
    .SYNOPSIS
        Exports scan metadata to JSON
    .DESCRIPTION
        Writes metadata including scan times, confidence counts, node/edge counts.
    .PARAMETER Topology
        Topology object
    .PARAMETER OutFile
        Output path for metadata JSON
    .PARAMETER CredSetsUsed
        Array of credential set names used during scan
    .EXAMPLE
        $topo | Export-Metadata -OutFile '.\scanmeta.json'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject]$Topology,

        [Parameter(Mandatory)]
        [string]$OutFile,

        [Parameter()]
        [string[]]$CredSetsUsed = @()
    )

    process {
        if ($null -eq $Topology) {
            throw "Invalid topology object"
        }

        # Calculate confidence counts
        $confidenceCounts = @{}
        foreach ($edge in $Topology.Edges) {
            if ($edge.PSObject.Properties['Confidence'] -and -not [string]::IsNullOrWhiteSpace($edge.Confidence)) {
                $conf = $edge.Confidence
                if ($confidenceCounts.ContainsKey($conf)) {
                    $confidenceCounts[$conf]++
                }
                else {
                    $confidenceCounts[$conf] = 1
                }
            }
        }

        $metadata = [ordered]@{
            scanStarted       = (Get-Date).ToString('o')
            scanFinished      = (Get-Date).ToString('o')
            tool              = "NetDiagram-PS $script:ModuleVersion"
            credSetsUsed      = @($CredSetsUsed)
            confidenceCounts  = $confidenceCounts
            nodeCount         = $Topology.Nodes.Count
            edgeCount         = $Topology.Edges.Count
        }

        try {
            $metadata | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutFile -Encoding utf8 -Force
            Write-Verbose "Exported metadata to $OutFile"
        }
        catch {
            throw "Failed to write metadata file: $_"
        }
    }
}

#endregion

#region Compare-NetworkScans

function Compare-NetworkScans {
    <#
    .SYNOPSIS
        Compares two network scan snapshots
    .DESCRIPTION
        Analyzes differences between two topology snapshots and metadata files.
        Outputs markdown report with nodes/edges added/removed and confidence changes.
    .PARAMETER BaselineMetadata
        Path to baseline metadata JSON
    .PARAMETER BaselineTopology
        Path to baseline topology JSON (serialized Topology object)
    .PARAMETER CurrentMetadata
        Path to current metadata JSON
    .PARAMETER CurrentTopology
        Path to current topology JSON
    .PARAMETER OutFile
        Output path for markdown report (optional, defaults to console)
    .EXAMPLE
        Compare-NetworkScans -BaselineMetadata '.\old-meta.json' -BaselineTopology '.\old-topo.json' `
                             -CurrentMetadata '.\new-meta.json' -CurrentTopology '.\new-topo.json' `
                             -OutFile '.\diff.md'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$BaselineMetadata,

        [Parameter(Mandatory)]
        [string]$BaselineTopology,

        [Parameter(Mandatory)]
        [string]$CurrentMetadata,

        [Parameter(Mandatory)]
        [string]$CurrentTopology,

        [Parameter()]
        [string]$OutFile
    )

    # Load files
    try {
        $baseMeta = Get-Content -Path $BaselineMetadata -Raw | ConvertFrom-Json
        $baseTopoData = Get-Content -Path $BaselineTopology -Raw | ConvertFrom-Json
        $currMeta = Get-Content -Path $CurrentMetadata -Raw | ConvertFrom-Json
        $currTopoData = Get-Content -Path $CurrentTopology -Raw | ConvertFrom-Json
    }
    catch {
        throw "Failed to load comparison files: $_"
    }

    $report = [System.Text.StringBuilder]::new()

    $null = $report.AppendLine("# Network Scan Comparison Report")
    $null = $report.AppendLine()

    # Safely handle timestamp strings with colons
    $baselineTime = if ($baseMeta.PSObject.Properties['scanFinished']) { $baseMeta.scanFinished } else { 'Unknown' }
    $currentTime = if ($currMeta.PSObject.Properties['scanFinished']) { $currMeta.scanFinished } else { 'Unknown' }

    $null = $report.AppendLine("**Baseline Scan:** $baselineTime")
    $null = $report.AppendLine("**Current Scan:** $currentTime")
    $null = $report.AppendLine()

    # Compare nodes
    $baseNodeIPs = @($baseTopoData.Nodes | ForEach-Object { $_.IP })
    $currNodeIPs = @($currTopoData.Nodes | ForEach-Object { $_.IP })

    $addedNodes = @($currNodeIPs | Where-Object { $_ -notin $baseNodeIPs })
    $removedNodes = @($baseNodeIPs | Where-Object { $_ -notin $currNodeIPs })

    $null = $report.AppendLine("## Node Changes")
    $null = $report.AppendLine()

    if ($addedNodes.Count -gt 0) {
        $null = $report.AppendLine("### Added Nodes ($($addedNodes.Count))")
        foreach ($ip in $addedNodes) {
            $node = $currTopoData.Nodes | Where-Object { $_.IP -eq $ip }
            $null = $report.AppendLine("- $ip ($($node.Hostname)) - $($node.Role)")
        }
        $null = $report.AppendLine()
    }

    if ($removedNodes.Count -gt 0) {
        $null = $report.AppendLine("### Removed Nodes ($($removedNodes.Count))")
        foreach ($ip in $removedNodes) {
            $node = $baseTopoData.Nodes | Where-Object { $_.IP -eq $ip }
            $null = $report.AppendLine("- $ip ($($node.Hostname)) - $($node.Role)")
        }
        $null = $report.AppendLine()
    }

    if ($addedNodes.Count -eq 0 -and $removedNodes.Count -eq 0) {
        $null = $report.AppendLine("No node changes detected.")
        $null = $report.AppendLine()
    }

    # Compare edges
    $baseEdgeKeys = @($baseTopoData.Edges | ForEach-Object {
        $endpoints = @($_.SourceIP, $_.TargetIP) | Sort-Object
        "$($endpoints[0])|$($endpoints[1])"
    })
    $currEdgeKeys = @($currTopoData.Edges | ForEach-Object {
        $endpoints = @($_.SourceIP, $_.TargetIP) | Sort-Object
        "$($endpoints[0])|$($endpoints[1])"
    })

    $addedEdges = @($currEdgeKeys | Where-Object { $_ -notin $baseEdgeKeys })
    $removedEdges = @($baseEdgeKeys | Where-Object { $_ -notin $currEdgeKeys })

    $null = $report.AppendLine("## Edge Changes")
    $null = $report.AppendLine()

    if ($addedEdges.Count -gt 0) {
        $null = $report.AppendLine("### Added Edges ($($addedEdges.Count))")
        foreach ($key in $addedEdges) {
            $edge = $currTopoData.Edges | Where-Object {
                $endpoints = @($_.SourceIP, $_.TargetIP) | Sort-Object
                "$($endpoints[0])|$($endpoints[1])" -eq $key
            }
            $null = $report.AppendLine("- $($edge.SourceIP) <-> $($edge.TargetIP) [$($edge.Confidence)]")
        }
        $null = $report.AppendLine()
    }

    if ($removedEdges.Count -gt 0) {
        $null = $report.AppendLine("### Removed Edges ($($removedEdges.Count))")
        foreach ($key in $removedEdges) {
            $edge = $baseTopoData.Edges | Where-Object {
                $endpoints = @($_.SourceIP, $_.TargetIP) | Sort-Object
                "$($endpoints[0])|$($endpoints[1])" -eq $key
            }
            $null = $report.AppendLine("- $($edge.SourceIP) <-> $($edge.TargetIP) [$($edge.Confidence)]")
        }
        $null = $report.AppendLine()
    }

    if ($addedEdges.Count -eq 0 -and $removedEdges.Count -eq 0) {
        $null = $report.AppendLine("No edge changes detected.")
        $null = $report.AppendLine()
    }

    # Confidence comparison
    $null = $report.AppendLine("## Confidence Distribution")
    $null = $report.AppendLine()
    $null = $report.AppendLine("| Confidence | Baseline | Current | Change |")
    $null = $report.AppendLine("|------------|----------|---------|--------|")

    # Safely extract confidence type names
    $baseConfidences = if ($baseMeta.PSObject.Properties['confidenceCounts'] -and $baseMeta.confidenceCounts) {
        @($baseMeta.confidenceCounts.PSObject.Properties | ForEach-Object { $_.Name })
    } else { @() }

    $currConfidences = if ($currMeta.PSObject.Properties['confidenceCounts'] -and $currMeta.confidenceCounts) {
        @($currMeta.confidenceCounts.PSObject.Properties | ForEach-Object { $_.Name })
    } else { @() }

    $allConfidences = @($baseConfidences + $currConfidences | Select-Object -Unique)

    foreach ($conf in $allConfidences) {
        $baseCount = if ($baseMeta.PSObject.Properties['confidenceCounts'] -and $baseMeta.confidenceCounts.PSObject.Properties[$conf]) {
            $baseMeta.confidenceCounts.$conf
        } else { 0 }

        $currCount = if ($currMeta.PSObject.Properties['confidenceCounts'] -and $currMeta.confidenceCounts.PSObject.Properties[$conf]) {
            $currMeta.confidenceCounts.$conf
        } else { 0 }

        $change = $currCount - $baseCount
        $changeStr = if ($change -gt 0) { "+$change" } elseif ($change -lt 0) { "$change" } else { "0" }

        $null = $report.AppendLine("| $conf | $baseCount | $currCount | $changeStr |")
    }

    $reportText = $report.ToString()

    if ($OutFile) {
        try {
            $reportText | Out-File -FilePath $OutFile -Encoding utf8 -Force
            Write-Verbose "Comparison report written to $OutFile"
        }
        catch {
            throw "Failed to write report file: $_"
        }
    }
    else {
        Write-Output $reportText
    }
}

#endregion

#region Level 1: Credential-Free Discovery Functions

function Get-LocalARPTable {
    <#
    .SYNOPSIS
        Retrieves the local ARP table to discover devices on the network
    .DESCRIPTION
        Parses the ARP cache to find MAC addresses and IP addresses of devices
        that have recently communicated with this host. No credentials required.
    .PARAMETER InterfaceAlias
        Optional network interface to filter results
    .EXAMPLE
        Get-LocalARPTable

        Returns all ARP entries from the local cache
    .EXAMPLE
        $devices = Get-LocalARPTable | Where-Object { $_.Type -eq 'dynamic' }

        Get only dynamically learned ARP entries
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$InterfaceAlias
    )

    try {
        $arpEntries = @()
        
        if ($IsWindows -or $PSVersionTable.PSVersion.Major -lt 6 -or $null -eq $IsWindows) {
            # Windows: Use Get-NetNeighbor
            $neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue

            if ($InterfaceAlias) {
                $neighbors = $neighbors | Where-Object { $_.InterfaceAlias -eq $InterfaceAlias }
            }

            foreach ($entry in $neighbors) {
                $arpEntries += [pscustomobject]@{
                    IPAddress       = $entry.IPAddress
                    MACAddress      = $entry.LinkLayerAddress
                    State           = $entry.State
                    InterfaceAlias  = $entry.InterfaceAlias
                    InterfaceIndex  = $entry.InterfaceIndex
                }
            }
        } elseif ($IsMacOS) {
            # macOS: Parse arp -an
            $arpOutput = & arp -an 2>&1
            foreach ($line in $arpOutput) {
                # Format: ? (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]
                if ($line -match '\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)\s+on\s+(\w+)') {
                    $ip = $matches[1]
                    $mac = $matches[2]
                    $interface = $matches[3]
                    
                    if (-not $InterfaceAlias -or $interface -eq $InterfaceAlias) {
                        $arpEntries += [pscustomobject]@{
                            IPAddress       = $ip
                            MACAddress      = $mac
                            State           = 'Reachable'
                            InterfaceAlias  = $interface
                            InterfaceIndex  = $null
                        }
                    }
                }
            }
        } elseif ($IsLinux) {
            # Linux: Parse ip neigh or arp
            $neighborOutput = & ip neigh show 2>&1
            if ($LASTEXITCODE -eq 0) {
                # Use ip neigh
                foreach ($line in $neighborOutput) {
                    # Format: 192.168.1.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
                    if ($line -match '(\d+\.\d+\.\d+\.\d+)\s+dev\s+(\w+)\s+lladdr\s+([0-9a-f:]+)\s+(\w+)') {
                        $ip = $matches[1]
                        $interface = $matches[2]
                        $mac = $matches[3]
                        $state = $matches[4]
                        
                        if (-not $InterfaceAlias -or $interface -eq $InterfaceAlias) {
                            $arpEntries += [pscustomobject]@{
                                IPAddress       = $ip
                                MACAddress      = $mac
                                State           = $state
                                InterfaceAlias  = $interface
                                InterfaceIndex  = $null
                            }
                        }
                    }
                }
            } else {
                # Fallback to arp command
                $arpOutput = & arp -an 2>&1
                foreach ($line in $arpOutput) {
                    if ($line -match '\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)') {
                        $arpEntries += [pscustomobject]@{
                            IPAddress       = $matches[1]
                            MACAddress      = $matches[2]
                            State           = 'Reachable'
                            InterfaceAlias  = $null
                            InterfaceIndex  = $null
                        }
                    }
                }
            }
        }

        Write-Verbose "Found $($arpEntries.Count) ARP entries"
        return $arpEntries
    }
    catch {
        Write-Warning "Failed to retrieve ARP table: $_"
        return @()
    }
}

function Resolve-IPHostname {
    <#
    .SYNOPSIS
        Performs reverse DNS lookup for IP addresses
    .DESCRIPTION
        Attempts to resolve hostnames from IP addresses using DNS PTR records.
        No credentials required.
    .PARAMETER IPAddress
        IP address or array of IP addresses to resolve
    .PARAMETER TimeoutSeconds
        DNS query timeout in seconds, 1-300 (default: 2). Enforced with an async lookup;
        addresses that do not resolve within the window are returned with Success = $false.
    .EXAMPLE
        Resolve-IPHostname -IPAddress '8.8.8.8'

        Resolves the hostname for Google DNS
    .EXAMPLE
        $ips = @('192.168.1.1', '192.168.1.10')
        $ips | Resolve-IPHostname

        Resolve multiple IP addresses
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$IPAddress,

        [Parameter()]
        [ValidateRange(1, 300)]
        [int]$TimeoutSeconds = 2
    )

    process {
        foreach ($ip in $IPAddress) {
            try {
                # Honor TimeoutSeconds: resolve asynchronously and wait at most
                # $TimeoutSeconds so a slow or unreachable resolver cannot hang the
                # pipeline. On timeout we return a failure object (the background task
                # is abandoned).
                $task = [System.Net.Dns]::GetHostEntryAsync($ip)

                if ($task.Wait([TimeSpan]::FromSeconds($TimeoutSeconds))) {
                    $result = $task.Result
                    [pscustomobject]@{
                        IPAddress = $ip
                        Hostname  = $result.HostName
                        Aliases   = $result.Aliases
                        Success   = $true
                    }
                }
                else {
                    Write-Verbose "DNS lookup for $ip timed out after $TimeoutSeconds second(s)"
                    [pscustomobject]@{
                        IPAddress = $ip
                        Hostname  = $null
                        Aliases   = @()
                        Success   = $false
                    }
                }
            }
            catch {
                [pscustomobject]@{
                    IPAddress = $ip
                    Hostname  = $null
                    Aliases   = @()
                    Success   = $false
                }
            }
        }
    }
}

function Get-MACVendor {
    <#
    .SYNOPSIS
        Looks up vendor information from MAC address OUI
    .DESCRIPTION
        Identifies the manufacturer of a network device based on its MAC address
        using the Organizationally Unique Identifier (OUI) lookup.
        No credentials required, uses built-in OUI database.
    .PARAMETER MACAddress
        MAC address in any common format (AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, AABBCCDDEEFF)
    .EXAMPLE
        Get-MACVendor -MACAddress '00:1A:A0:12:34:56'

        Returns vendor information for the MAC address
    .EXAMPLE
        Get-LocalARPTable | ForEach-Object { Get-MACVendor -MACAddress $_.MACAddress }

        Get vendor info for all devices in ARP table
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$MACAddress
    )

    begin {
        # Common OUI prefix database (top vendors)
        $ouiDatabase = @{
            '00:1A:A0' = 'Dell'
            '00:50:56' = 'VMware'
            '00:0C:29' = 'VMware'
            '00:05:69' = 'VMware'
            '08:00:27' = 'Oracle VirtualBox'
            '52:54:00' = 'QEMU/KVM'
            '00:15:5D' = 'Microsoft Hyper-V'
            '00:03:FF' = 'Microsoft'
            'DC:A6:32' = 'Raspberry Pi'
            'B8:27:EB' = 'Raspberry Pi'
            'E4:5F:01' = 'Raspberry Pi'
            '00:0A:95' = 'Apple'
            'AC:DE:48' = 'Apple'
            '00:1B:63' = 'Apple'
            '00:25:00' = 'Apple'
            '28:6A:BA' = 'Apple'
            '00:23:12' = 'Cisco'
            '00:1E:14' = 'Cisco'
            '00:26:0A' = 'Cisco'
            'D0:D0:FD' = 'Cisco'
            '00:04:96' = 'Cisco'
            '00:E0:4C' = 'Realtek'
            '00:27:22' = 'TP-Link'
            '50:C7:BF' = 'TP-Link'
            '00:50:F2' = 'Microsoft'
            '00:12:3F' = 'Dell'
            '00:14:22' = 'Dell'
            '00:1E:C9' = 'HP'
            '00:21:5A' = 'HP'
            '00:30:6E' = 'Netgear'
            '00:09:5B' = 'Netgear'
        }
    }

    process {
        foreach ($mac in $MACAddress) {
            if ([string]::IsNullOrWhiteSpace($mac)) {
                continue
            }

            $oui = $null
            $vendor = 'Unknown'

            # Remove non-hex characters and normalize to AA:BB:CC:DD:EE:FF
            $hexOnly = ($mac -replace '[^0-9A-Fa-f]', '').ToUpper()

            if ($hexOnly.Length -ge 12) {
                $hexOnly = $hexOnly.Substring(0, 12)

                $octets = for ($i = 0; $i -lt 12; $i += 2) {
                    $hexOnly.Substring($i, 2)
                }

                $normalizedMAC = ($octets -join ':')
                $oui = ($octets[0..2] -join ':')

                if ($ouiDatabase.ContainsKey($oui)) {
                    $vendor = $ouiDatabase[$oui]
                }
            }
            else {
                $normalizedMAC = $mac.ToUpper()
                $vendor = 'Invalid MAC'
            }

            [pscustomobject]@{
                MACAddress = $normalizedMAC
                OUI        = $oui
                Vendor     = $vendor
            }
        }
    }
}

function Invoke-PortScan {
    <#
    .SYNOPSIS
        Scans common network service ports on target hosts
    .DESCRIPTION
        Performs TCP port scanning and banner grabbing on common service ports.
        No credentials required. Useful for identifying services and device types.
    .PARAMETER IPAddress
        Target IP address or array of IP addresses
    .PARAMETER Ports
        Array of TCP ports to scan, 1-65535 (default: common TCP service ports).
        Note: SNMP (161) is UDP and is deliberately NOT in the defaults - a TCP probe of
        161 does not detect SNMP. Use Invoke-SnmpWalk for SNMP.
    .PARAMETER TimeoutMs
        Per-port connection timeout in milliseconds, 1-60000 (default: 1000)
    .PARAMETER GrabBanners
        Attempt to grab service banners from open ports
    .EXAMPLE
        Invoke-PortScan -IPAddress '192.168.1.1'

        Scan common ports on a single host
    .EXAMPLE
        Invoke-PortScan -IPAddress '192.168.1.1' -Ports @(80,443,22) -GrabBanners

        Scan specific ports and grab banners
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$IPAddress,

        [Parameter()]
        [ValidateRange(1, 65535)]
        [int[]]$Ports = @(21, 22, 23, 25, 80, 443, 445, 3389, 8080, 8443),

        [Parameter()]
        [ValidateRange(1, 60000)]
        [int]$TimeoutMs = 1000,

        [Parameter()]
        [switch]$GrabBanners
    )

    process {
        foreach ($ip in $IPAddress) {
            Write-Verbose "Scanning $ip..."

            $openPorts = @()

            foreach ($port in $Ports) {
                $tcpClient = [System.Net.Sockets.TcpClient]::new()
                $connectResult = $null

                try {
                    $connectResult = $tcpClient.BeginConnect($ip, $port, $null, $null)
                    $connectedInTime = $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)

                    if (-not $connectedInTime) {
                        continue
                    }

                    try {
                        $tcpClient.EndConnect($connectResult)
                    }
                    catch {
                        continue
                    }

                    if (-not $tcpClient.Connected) {
                        continue
                    }

                    $banner = $null

                    if ($GrabBanners) {
                        try {
                            $stream = $tcpClient.GetStream()
                            if ($stream.CanRead) {
                                $stream.ReadTimeout = 500
                                $buffer = New-Object byte[] 1024
                                $bytesRead = $stream.Read($buffer, 0, 1024)
                                if ($bytesRead -gt 0) {
                                    $banner = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $bytesRead).Trim()
                                }
                            }
                        }
                        catch {
                            # Banner grab failed, that's OK
                        }
                    }

                    $openPorts += [pscustomobject]@{
                        Port    = $port
                        State   = 'Open'
                        Service = Get-ServiceName -Port $port
                        Banner  = $banner
                    }

                    Write-Verbose " Port $port - Open"
                }
                catch {
                    # Port closed or filtered
                }
                finally {
                    if ($connectResult -and $connectResult.AsyncWaitHandle) {
                        $connectResult.AsyncWaitHandle.Close()
                    }
                    $tcpClient.Dispose()
                }
            }

            [pscustomobject]@{
                IPAddress = $ip
                OpenPorts = $openPorts
                ScanTime  = Get-Date
            }
        }
    }
}

function Get-ServiceName {
    <#
    .SYNOPSIS
        Maps port numbers to common service names
    #>

    param([int]$Port)

    $services = @{
        21   = 'FTP'
        22   = 'SSH'
        23   = 'Telnet'
        25   = 'SMTP'
        53   = 'DNS'
        80   = 'HTTP'
        110  = 'POP3'
        143  = 'IMAP'
        161  = 'SNMP'
        443  = 'HTTPS'
        445  = 'SMB'
        3389 = 'RDP'
        8080 = 'HTTP-ALT'
        8443 = 'HTTPS-ALT'
    }

    if ($services.ContainsKey($Port)) {
        return $services[$Port]
    }
    return "Unknown"
}

#endregion

#region Level 3: Default Credential Testing (Opt-In)

function Get-CommonSNMPStrings {
    <#
    .SYNOPSIS
        Returns a list of common default SNMP community strings
    .DESCRIPTION
        Provides the most commonly used default SNMP community strings
        for testing. For security research and authorized testing only.
    .EXAMPLE
        Get-CommonSNMPStrings

        Returns array of common strings
    #>

    [CmdletBinding()]
    param()

    @(
        'public'
        'private'
        'community'
        'snmp'
        'manager'
        'monitor'
        'cisco'
        'admin'
        'security'
        'default'
    )
}

#endregion

# Export all public functions
Export-ModuleMember -Function @(
    # Core inventory and topology
    'Import-Inventory'
    'Test-DeviceReachability'
    'Merge-Edges'

    # Level 1: Credential-Free Discovery
    'Get-LocalARPTable'
    'Resolve-IPHostname'
    'Get-MACVendor'
    'Invoke-PortScan'

    # Level 2 & 3: SNMP Discovery
    'Invoke-SnmpWalk'
    'Get-SnmpNeighbors'
    'Get-CommonSNMPStrings'

    # Export and Analysis
    'Export-DrawIO'
    'Export-Metadata'
    'Compare-NetworkScans'
)