Eigenverft.Manifested.Drydock.IO.ps1

function Find-FilesByPattern {
<#
.SYNOPSIS
    Robust, manual (no -Recurse) filename search with Windows wildcards.
.DESCRIPTION
    Traverses the tree with an explicit stack (deep-first), continues on errors,
    and matches file names against one or more wildcard patterns (e.g. *.txt, *.sln).
    Returns an array of FileInfo objects.
.PARAMETER Path
    Root directory where the search should begin.
.PARAMETER Pattern
    Filename wildcard(s). Accepts array or comma/semicolon list (e.g. "*.txt;*.csproj").
.EXAMPLE
    $files = Find-FilesByPattern -Path "C:\MyProjects" -Pattern "*.txt;*.md"
    foreach ($f in $files) { $f.FullName }
#>

    [CmdletBinding()]
    [Alias("ffbp")]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Path,

        [Parameter(Mandatory=$true)]
        [string[]]$Pattern
    )

    # Validate root
    if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
        throw "The specified path '$Path' does not exist or is not a directory."
    }

    function _Norm-List {
        [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")]
        param([string[]]$Patterns)
        if (-not $Patterns) { return @() }
        $list = New-Object System.Collections.Generic.List[string]
        foreach ($p in $Patterns) {
            if ([string]::IsNullOrWhiteSpace($p)) { continue }
            $p -split '[,;]' | ForEach-Object {
                $t = $_.Trim()
                if ($t) { [void]$list.Add($t) }
            }
        }
        if ($list.Count -eq 0) { return @() }
        $list.ToArray()
    }

    # Normalize patterns (supports array or "a;b,c" lists)
    $pat = _Norm-List $Pattern
    if (-not $pat -or $pat.Count -eq 0) {
        throw "Pattern must not be empty."
    }

    # Prepare traversal
    try { $rootItem = Get-Item -LiteralPath $Path -ErrorAction Stop }
    catch { throw "Path '$Path' is not accessible: $_" }

    $stack   = New-Object System.Collections.Stack
    $stack.Push($rootItem)

    $fmatches = New-Object System.Collections.Generic.List[System.IO.FileInfo]

    while ($stack.Count -gt 0) {
        $dir = $stack.Pop()

        # Discover subdirectories
        $subs = @()
        try   { $subs = Get-ChildItem -LiteralPath $dir.FullName -Directory -ErrorAction Stop }
        catch { Write-Warning "Cannot list directories in '$($dir.FullName)': $_" }
        foreach ($sd in $subs) { $stack.Push($sd) }

        # Files in this directory
        $files = @()
        try   { $files = Get-ChildItem -LiteralPath $dir.FullName -File -ErrorAction Stop }
        catch { Write-Warning "Cannot list files in '$($dir.FullName)': $_" }

        foreach ($f in $files) {
            foreach ($p in $pat) {
                if ($f.Name -like $p) { [void]$fmatches.Add($f); break }
            }
        }
    }

    return @($fmatches.ToArray())
}

function Find-TreeContent {
<#
.SYNOPSIS
Manual deepest-first file discovery + Windows-style wildcard text search with smart AUTO fallback.
 
.DESCRIPTION
Two-phase search:
  1) Manual, error-resilient traversal (no -Recurse). Collect candidate files using Windows wildcards (*, ?, [...]).
  2) Scan candidates’ contents with the SAME wildcard rules for -FindText (case-insensitive, classic Windows feel).
 
Escaping (for BOTH filename and text patterns):
  - Use [*] for literal asterisk, and [?] for literal question mark.
  - Or escape with backslash: \* \? \[ \] or backtick: `* `? `[ `]
  - Literal '[' or ']' can also be matched via '[[]' or '[]]'.
 
Encoding & AUTO mode:
  - Primary pass uses StreamReader with built-in BOM detection; fallback is Encoding.Default (ANSI/UTF-8 depending on OS).
  - If primary pass finds no matches AND no BOM was detected (i.e., Default was used),
    -Auto tries encodings in this order: UTF-8 → UTF-16 LE, and stops on the first that matches.
  - No binary scanning is performed.
 
Output:
  - Always returns an **array** of result objects with:
      Path, LineNumber, Snippet, Encoding, Newline
  - `Snippet` is trimmed around the match (configurable via -MaxLineChars).
 
Traversal order:
  - Files are processed from the deepest directory upward.
 
Robustness:
  - Directory/file enumeration and reads continue on errors (warnings only).
  - Single size guard: -MaxFileSizeBytes (applies to text scanning).
 
.PARAMETER Path
Root directory to search.
 
.PARAMETER Include
Filename wildcards (*, ?, [...]). Accepts array or comma/semicolon list. Default: '*'.
 
.PARAMETER Exclude
Wildcard(s) to skip (matched against FullName). Accepts array or comma/semicolon list.
 
.PARAMETER FindText
Content pattern using Windows wildcards (*, ?, [...]) with the escaping rules above.
 
.PARAMETER Auto
When no BOM is detected and the first pass finds no matches, try UTF-8 then UTF-16 LE (stop on first success).
 
.PARAMETER MaxFileSizeBytes
Max size per file for scanning. Default: 64MB.
 
.PARAMETER MaxLineChars
Max characters kept in the returned Snippet (centered on match). Default: 256.
 
.PARAMETER ShowProgress
Write-Host discovery and match feedback.
 
.EXAMPLE
Find-TreeContent -Path C:\repo -Include "*.cs;*.ps1" -FindText "*TODO?*" -ShowProgress
 
.EXAMPLE
Find-TreeContent -Path $PWD -Include "*" -Exclude "*\bin\*;*/obj/*" -FindText "[*]CRITICAL[*]" -ShowProgress
 
.EXAMPLE
Find-TreeContent -Path . -Include "*" -FindText "mani" -Auto | Format-Table
#>

    [CmdletBinding()]
    [Alias('ftc','deepfind')]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Path,

        [Parameter()]
        [string[]]$Include = @('*'),

        [Parameter()]
        [string[]]$Exclude,

        [Parameter(Mandatory=$true)]
        [string]$FindText,

        [switch]$Auto,

        [ValidateRange(1, 1GB)]
        [int]$MaxFileSizeBytes = 1048576,

        [ValidateRange(32, 4096)]
        [int]$MaxLineChars = 256,

        [switch]$ShowProgress
    )

    # ------------------------ Helpers ------------------------

    function _Norm-List {
        [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")]
        param([string[]]$Patterns)
        if (-not $Patterns) { return @() }
        $list = New-Object System.Collections.Generic.List[string]
        foreach ($p in $Patterns) {
            if ([string]::IsNullOrWhiteSpace($p)) { continue }
            $p -split '[,;]' | ForEach-Object {
                $t = $_.Trim()
                if ($t) { [void]$list.Add($t) }
            }
        }
        if ($list.Count -eq 0) { return @() }
        $list.ToArray()
    }

    function _WildcardToRegex {
        param([Parameter(Mandatory=$true)][string]$Pattern)
        # Convert Windows wildcards to a .NET regex (case-insensitive)
        $sb  = New-Object System.Text.StringBuilder
        $i   = 0
        $len = $Pattern.Length
        while ($i -lt $len) {
            $ch = $Pattern[$i]

            if (($ch -eq '\' -or $ch -eq '`') -and ($i + 1 -lt $len)) {
                $nx = $Pattern[$i+1]
                if ($nx -in @('*','?','[',']','\','`')) {
                    [void]$sb.Append([System.Text.RegularExpressions.Regex]::Escape([string]$nx))
                    $i += 2
                    continue
                }
            }

            if ($ch -eq '*') { [void]$sb.Append('.*'); $i++; continue }
            if ($ch -eq '?') { [void]$sb.Append('.');  $i++; continue }

            if ($ch -eq '[') {
                $j = $i + 1
                while ($j -lt $len -and $Pattern[$j] -ne ']') { $j++ }
                if ($j -lt $len -and $Pattern[$j] -eq ']') {
                    $content = $Pattern.Substring($i+1, $j-$i-1)
                    if ($content.Length -gt 0 -and $content[0] -eq '!') { $content = '^' + $content.Substring(1) }
                    $content = $content -replace '([\\\]\[.{}()+|$])','\\$1'
                    [void]$sb.Append('[' + $content + ']')
                    $i = $j + 1; continue
                }
                [void]$sb.Append('\['); $i++; continue
            }

            if ($ch -eq ']') { [void]$sb.Append('\]'); $i++; continue }
            [void]$sb.Append([System.Text.RegularExpressions.Regex]::Escape([string]$ch)); $i++
        }
        return '(?i:' + $sb.ToString() + ')'
    }

    function _GetEncodingByWebName([string]$name) {
        try { return [System.Text.Encoding]::GetEncoding($name) } catch { return $null }
    }

    function _MakeSnippet([string]$line, [int]$idx, [int]$len, [int]$maxChars) {
        if ($null -eq $line) { return $null }
        if ($line.Length -le $maxChars) { return $line }
        $context = [Math]::Max(0, [Math]::Floor(($maxChars - $len) / 2))
        $start = [Math]::Max(0, $idx - $context)
        if ($start + $maxChars -gt $line.Length) { $start = $line.Length - $maxChars }
        $slice = $line.Substring($start, [Math]::Min($maxChars, $line.Length - $start))
        $prefix = if ($start -gt 0) { '…' } else { '' }
        $suffix = if (($start + $slice.Length) -lt $line.Length) { '…' } else { '' }
        return "$prefix$slice$suffix"
    }

    function _Detect-Newline {
        [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")]
        param(
            [string]$Path,
            [System.Text.Encoding]$Encoding,
            [int]$MaxBytes = 131072
        )
        try {
            $fi = Get-Item -LiteralPath $Path -ErrorAction Stop
        } catch { return 'Unknown' }

        $readLen = [Math]::Min($fi.Length, [long]$MaxBytes)
        if ($readLen -le 0) { return 'Unknown' }

        try {
            $fs = [System.IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite')
            $buf = New-Object byte[] $readLen
            $null = $fs.Read($buf, 0, $buf.Length)
            $fs.Dispose()
        } catch { return 'Unknown' }

        try { $txt = $Encoding.GetString($buf, 0, $buf.Length) } catch { return 'Unknown' }

        $crlf = ([regex]::Matches($txt, "`r`n")).Count
        $txt2 = $txt -replace "`r`n", ''
        $cr   = ([regex]::Matches($txt2, "`r")).Count
        $lf   = ([regex]::Matches($txt2, "`n")).Count

        if ($crlf -gt 0 -and $cr -eq 0 -and $lf -eq 0) { return 'CRLF' }
        if ($crlf -eq 0 -and $cr -eq 0 -and $lf -gt 0) { return 'LF' }
        if ($crlf -eq 0 -and $cr -gt 0 -and $lf -eq 0) { return 'CR' }
        if ($crlf -eq 0 -and $crlf -eq 0 -and $cr -eq 0 -and $lf -eq 0) { return 'Unknown' }
        return 'Mixed'
    }

    function _Discover-Files {
        [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")]
        param(
            [string]$Root,
            [string[]]$Inc,
            [string[]]$Exc,
            [switch]$Progress
        )
        try { $rootItem = Get-Item -LiteralPath $Root -ErrorAction Stop }
        catch { throw "Path '$Root' is not accessible: $_" }

        $stack = New-Object System.Collections.Stack
        $stack.Push(@{ Item = $rootItem; Depth = 0 })

        $files = New-Object System.Collections.Generic.List[object]
        function _SegCount([string]$p) {
            if (-not $p) { return 0 }
            return ($p.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) -split '[\\/]+').Count
        }
        $rootSegs = _SegCount $rootItem.FullName

        while ($stack.Count -gt 0) {
            $frame = $stack.Pop()
            $dir   = $frame.Item
            $depth = $frame.Depth
            if ($Progress) { Write-Host "[DIR] $($dir.FullName)" -ForegroundColor Cyan }

            $subs = @()
            try   { $subs = Get-ChildItem -LiteralPath $dir.FullName -Directory -ErrorAction Stop }
            catch { Write-Warning "Cannot list directories in '$($dir.FullName)': $_" }

            foreach ($sd in $subs) { $stack.Push(@{ Item = $sd; Depth = $depth + 1 }) }

            $dirFiles = @()
            try   { $dirFiles = Get-ChildItem -LiteralPath $dir.FullName -File -ErrorAction Stop }
            catch { Write-Warning "Cannot list files in '$($dir.FullName)': $_" }

            foreach ($f in $dirFiles) {
                $name = $f.Name
                $full = $f.FullName
                $ok = $false
                foreach ($p in $Inc) { if ($name -like $p) { $ok = $true; break } }
                if (-not $ok) { continue }

                if ($Exc -and $Exc.Count -gt 0) {
                    $skip = $false
                    foreach ($e in $Exc) { if ($full -like $e) { $skip = $true; break } }
                    if ($skip) { continue }
                }

                $depthNow = [Math]::Max(0, (_SegCount $f.DirectoryName) - $rootSegs)
                if ($Progress) { Write-Host " [FILE] $full" -ForegroundColor DarkGray }
                $files.Add([pscustomobject]@{
                    FullName = $full
                    Name     = $name
                    Depth    = [int]$depthNow
                    Length   = $f.Length
                }) | Out-Null
            }
        }

        $files | Sort-Object -Property `
            @{ Expression = 'Depth'; Descending = $true }, `
            @{ Expression = 'Name' ; Descending = $false }
    }

    function _Scan-TextSmart {
        [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")]
        param(
            [string]$Path,
            [string]$RegexPattern,
            [int]$MaxBytes,
            [int]$MaxChars,
            [switch]$AllowFallback
        )
        $fi = $null
        try { $fi = Get-Item -LiteralPath $Path -ErrorAction Stop }
        catch { Write-Warning "Cannot access '$Path': $_"; return @() }

        if ($fi.Length -gt $MaxBytes) { Write-Verbose "Skip > MaxFileSizeBytes: $Path"; return @() }

        $rx = New-Object System.Text.RegularExpressions.Regex($RegexPattern, [System.Text.RegularExpressions.RegexOptions]::Compiled)

        $results = New-Object System.Collections.Generic.List[object]
        $primaryEnc = $null

        # pass 1: BOM-aware (Default fallback)
        $fs = $null; $sr = $null
        try {
            $fs = [System.IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite')
            $sr = New-Object System.IO.StreamReader($fs, [System.Text.Encoding]::Default, $true)
            $null = $sr.Peek()
            $primaryEnc = $sr.CurrentEncoding

            $lineNo = 0
            while ($null -ne ($line = $sr.ReadLine())) {
                $lineNo++
                $m = $rx.Match($line)
                if ($m.Success) {
                    $snippet = (_MakeSnippet $line $m.Index $m.Length $MaxChars).Trim()
                    $results.Add([pscustomobject]@{
                        Path       = $Path
                        LineNumber = $lineNo
                        Snippet    = $snippet
                        Encoding   = $sr.CurrentEncoding.WebName
                    }) | Out-Null
                }
            }
        } catch {
            Write-Warning "Text scan failed: '$Path': $_"
        } finally {
            if ($sr) { $sr.Dispose() } elseif ($fs) { $fs.Dispose() }
        }

        if ($results.Count -gt 0) { return $results.ToArray() }

        # pass 2: AUTO fallback (only if allowed and no BOM recognized -> Default used)
        if ($AllowFallback -and $primaryEnc -and ($primaryEnc.WebName -eq [System.Text.Encoding]::Default.WebName)) {
            foreach ($enc in @([System.Text.Encoding]::UTF8, [System.Text.Encoding]::Unicode)) {
                $fs2 = $null; $sr2 = $null
                try {
                    $fs2 = [System.IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite')
                    $sr2 = New-Object System.IO.StreamReader($fs2, $enc, $false)

                    $lineNo2 = 0
                    while ($null -ne ($line2 = $sr2.ReadLine())) {
                        $lineNo2++
                        $m2 = $rx.Match($line2)
                        if ($m2.Success) {
                            $snippet2 = (_MakeSnippet $line2 $m2.Index $m2.Length $MaxChars).Trim()
                            $results.Add([pscustomobject]@{
                                Path       = $Path
                                LineNumber = $lineNo2
                                Snippet    = $snippet2
                                Encoding   = $enc.WebName
                            }) | Out-Null
                        }
                    }
                } catch {
                    Write-Verbose "Alt-encoding scan failed for '$Path' with $($enc.WebName): $_"
                } finally {
                    if ($sr2) { $sr2.Dispose() } elseif ($fs2) { $fs2.Dispose() }
                }
                if ($results.Count -gt 0) { break } # stop on first successful encoding
            }
        }

        return $results.ToArray()
    }

    # ------------------------ Validate & prepare ------------------------

    if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
        throw "Path '$Path' does not exist or is not a directory."
    }

    $inc = _Norm-List $Include
    if (-not $inc -or $inc.Count -eq 0) { $inc = @('*') }
    $exc = _Norm-List $Exclude

    $regexPattern = _WildcardToRegex -Pattern $FindText

    if ($ShowProgress) { Write-Host "[DISCOVERY] Enumerating '$Path'..." -ForegroundColor Yellow }

    # ------------------------ Phase 1: discovery ------------------------
    $candidates = _Discover-Files -Root $Path -Inc $inc -Exc $exc -Progress:$ShowProgress

    if ($ShowProgress) {
        Write-Host "[DISCOVERY] $($candidates.Count) candidate file(s) found." -ForegroundColor Yellow
        Write-Host "[SCAN] Processing deepest files first..." -ForegroundColor Yellow
    }

    # ------------------------ Phase 2: scanning ------------------------
    $allResults = New-Object System.Collections.Generic.List[object]

    foreach ($f in $candidates) {
        $p = $f.FullName
        if ($ShowProgress) { Write-Host "[SCAN] $p" -ForegroundColor DarkCyan }

        $hits = _Scan-TextSmart -Path $p -RegexPattern $regexPattern -MaxBytes $MaxFileSizeBytes -MaxChars $MaxLineChars -AllowFallback:$Auto
        if ($hits -and $hits.Count -gt 0) {
            # Determine newline once for the file, attach to each hit
            $encObj = _GetEncodingByWebName ($hits[0].Encoding)
            $nl = if ($encObj) { _Detect-Newline -Path $p -Encoding $encObj } else { 'Unknown' }

            foreach ($h in $hits) {
                # add Newline & (optional) progress line
                $h | Add-Member -NotePropertyName Newline -NotePropertyValue $nl
                if ($ShowProgress) { Write-Host " [+] L$($h.LineNumber) ($($h.Encoding), $nl) $($h.Snippet)" -ForegroundColor Green }
                [void]$allResults.Add($h)
            }
        }
    }

    # Always return an array (even when 0 or 1)
    $out = $allResults.ToArray()
    return @($out)
}

function Find-TreeContentByFile {
<#
.SYNOPSIS
Call Find-TreeContent and group results per file.
 
.DESCRIPTION
Invokes Find-TreeContent with the same parameters, then returns one object per file:
  - FileName : full path (group key, i.e., $g.Name)
  - Encoding : single encoding for the file (from first match)
  - Newline : single newline style for the file
  - ITEMS : array of { LineNumber, Snippet } sorted by line
 
.PARAMETER Path
Root directory to search (passed through).
 
.PARAMETER Include
Filename wildcards (passed through).
 
.PARAMETER Exclude
Wildcard(s) to skip (passed through).
 
.PARAMETER FindText
Content wildcard (passed through).
 
.PARAMETER Auto
Enable encoding auto-fallback (passed through).
 
.PARAMETER MaxFileSizeBytes
Per-file size cap for scanning (passed through).
 
.PARAMETER MaxLineChars
Snippet width (passed through).
 
.PARAMETER ShowProgress
Discovery/match feedback (passed through).
 
.EXAMPLE
Find-TreeContentByFile -Path . -Include "*.cs;*.ps1" -FindText "*TODO?*"
 
.EXAMPLE
Find-TreeContentByFile -Path C:\repo -FindText "[*]CRITICAL[*]" -Auto | Format-List
#>

    [CmdletBinding()]
    [Alias('ftc-byfile','deepfind-byfile')]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Path,

        [Parameter()]
        [string[]]$Include = @('*'),

        [Parameter()]
        [string[]]$Exclude,

        [Parameter(Mandatory=$true)]
        [string]$FindText,

        [switch]$Auto,

        [ValidateRange(1, 1GB)]
        [int]$MaxFileSizeBytes = 1048576,

        [ValidateRange(32, 4096)]
        [int]$MaxLineChars = 256,

        [switch]$ShowProgress
    )

    # Call the underlying function exactly with the same parameters
    $results = Find-TreeContent @PSBoundParameters
    if (-not $results) { return @() }

    $groups = $results | Group-Object -Property Path

    $out = foreach ($g in $groups) {
        $first = $g.Group | Select-Object -First 1
        [pscustomobject]@{
            FileName = $g.Name
            Encoding = $first.Encoding
            Newline  = $first.Newline
            Lines    = ($g.Group | Sort-Object LineNumber | Select-Object LineNumber, Snippet)
        }
    }

    return @($out)
}