param(
    [string]$Source = $PSScriptRoot,
    # When set, no files are copied or deleted -- output shows what *would* happen.
    # Use to verify the destination list, .pkgmeta parsing, and skip patterns
    # before letting the watcher actually mutate target installs.
    [switch]$DryRun
)

$AddonName   = "TOGTools"
$WowBase     = "${env:ProgramFiles(x86)}\World of Warcraft"

# -----------------------------------------------------------------------------
# Single-instance guard -- PER REPO, not per-script-name.
#
# This same watcher script runs concurrently in many addon repos. We must NOT
# bail just because *a* wow-version-replication.ps1 is running somewhere else
# (that's a different addon). The guard is keyed on THIS repo's own source path,
# so:
#   * a second launch on the SAME repo (e.g. a VS Code window reload firing the
#     folderOpen task again) finds the mutex held and exits cleanly -- no racing
#     watchers mangling the same files;
#   * a watcher for a DIFFERENT addon has a different source path -> different
#     mutex name -> both run happily side by side.
#
# A named Mutex (kernel object) is used rather than scanning process command
# lines, because command-line matching is fragile: relative vs absolute launch
# paths, quoting, and "which addon is this" are all unreliable to parse. The
# mutex is released automatically when this process exits (crash, Ctrl+C, or
# normal stop), so a dead watcher never blocks a fresh one.
#
# Skipped in -DryRun: a dry run is a read-only diagnostic and should always run
# even while a real watcher holds the mutex.
$script:InstanceMutex = $null
if (-not $DryRun) {
    # Resolve to a canonical absolute path, lowercased, so the same repo always
    # yields the same key regardless of how it was launched. Non-alphanumeric
    # chars -> '_' because mutex names can't contain '\'.
    $repoKey = (Resolve-Path -LiteralPath $Source -ErrorAction SilentlyContinue).Path
    if (-not $repoKey) { $repoKey = $Source }
    $repoKey = ($repoKey.ToLowerInvariant() -replace '[^a-z0-9]', '_')
    # "Local\" (per-session) NOT "Global\": creating a Global\ mutex needs
    # SeCreateGlobalPrivilege, which a standard (non-elevated) user lacks, so
    # New-Object threw UnauthorizedAccessException -> the catch below failed
    # OPEN (createdNew=true) and the guard never deduped, allowing duplicate
    # watchers. The watcher and its relaunchers always share one interactive
    # session, so a session-local mutex is the correct scope anyway.
    $mutexName = "Local\WowDevSync_$repoKey"
    $createdNew = $false
    try {
        $script:InstanceMutex = New-Object System.Threading.Mutex($true, $mutexName, [ref]$createdNew)
    } catch {
        # If the mutex can't be created for any reason, fail open (run anyway)
        # rather than refusing to sync.
        $createdNew = $true
    }
    if (-not $createdNew) {
        Write-Host "A dev sync watcher is already running for this repo ($Source). Exiting." -ForegroundColor Yellow
        exit 0
    }
}
# All WoW client versions TOGTools supports -- see the per-flavor .toc files
# (TOGTools.toc, TOGTools_BCC.toc, TOGTools_Wrath.toc, TOGTools_Cata.toc,
# TOGTools_Mists.toc, TOGTools_Mainline.toc). The script copies into every
# version directory that exists on disk, except the one the source tree
# already lives in (avoids copying onto itself).
$WowVersions = @("_classic_era_", "_classic_", "_anniversary_", "_retail_")

# Build list of addon install directories that actually exist on disk
$Destinations = foreach ($ver in $WowVersions) {
    $addonsDir = Join-Path $WowBase "$ver\Interface\AddOns"
    $dest = Join-Path $addonsDir $AddonName
    if ((Test-Path $addonsDir) -and ($Source -notlike "$dest*")) {
        if (-not (Test-Path $dest) -and -not $DryRun) {
            New-Item -ItemType Directory -Path $dest -Force | Out-Null
        }
        $dest
    }
}

if (-not $Destinations) {
    Write-Host "No WoW installation found under $WowBase. Exiting." -ForegroundColor Red
    exit 1
}

# -----------------------------------------------------------------------------
# Skip-pattern construction
#
# The goal is for the synced target installs to look like the BigWigs-packaged
# release: only the files that ship to CurseForge, no git/IDE/dev metadata.
# So the skip list is built from two sources:
#   1) .pkgmeta `ignore:` entries -- the canonical "not in the zip" list.
#      Parsed at script start so updates to .pkgmeta automatically apply.
#   2) Always-skip -- git metadata and the script itself, regardless of .pkgmeta.
# -----------------------------------------------------------------------------

function Convert-GlobToRegex([string]$glob) {
    # Normalize forward slashes to backslashes (file paths come in as Windows-style)
    $g = $glob -replace '/', '\'

    # "**\" prefix -> match anywhere in the tree, not just root
    $matchAnywhere = $false
    if ($g.StartsWith('**\')) {
        $g = $g.Substring(3)
        $matchAnywhere = $true
    }

    # Trailing "\" marks a directory; strip it for the literal match
    $isDir = $g.EndsWith('\')
    if ($isDir) {
        $g = $g.TrimEnd('\')
    }

    # Escape regex specials, then turn glob wildcards back into regex equivalents
    $escaped = [regex]::Escape($g)
    $escaped = $escaped -replace '\\\*', '[^\\]*'   # * -> any segment-internal chars
    $escaped = $escaped -replace '\\\?', '[^\\]'    # ? -> one char

    $prefix = if ($matchAnywhere) { '(^|\\)' } else { '^' }
    $suffix = if ($isDir)         { '(\\|$)' } else { '$' }
    return "$prefix$escaped$suffix"
}

function Get-PkgmetaIgnores([string]$pkgmetaPath) {
    if (-not (Test-Path $pkgmetaPath)) { return @() }
    $ignores  = @()
    $inIgnore = $false
    foreach ($raw in Get-Content $pkgmetaPath) {
        # Strip comments
        $line = $raw -replace '#.*$', ''
        if ($line -match '^ignore:\s*$') {
            $inIgnore = $true
            continue
        }
        if (-not $inIgnore) { continue }
        # End of ignore block: any line that starts at column 0 with non-whitespace,
        # non-list-marker content (i.e. a new top-level YAML key)
        if ($line -match '^\S' -and $line -notmatch '^-') {
            $inIgnore = $false
            continue
        }
        if ($line -match '^\s*-\s+(.+?)\s*$') {
            $entry = $matches[1].Trim()
            # Strip surrounding quotes (single or double)
            $entry = $entry -replace '^["'']', '' -replace '["'']$', ''
            if ($entry) { $ignores += $entry }
        }
    }
    return $ignores
}

# Always-skip -- git metadata + the sync script itself. These are belt-and-
# suspenders against .pkgmeta drift; even if someone removes them from .pkgmeta
# they should never end up in a target install.
$AlwaysSkip = @(
    '(^|\\)\.git(\\|$)',
    '^\.gitignore$',
    '^\.gitattributes$',
    '^\.gitmodules$',
    '(^|\\)wow-version-replication\.ps1$'
)

$pkgmetaPath = Join-Path $Source ".pkgmeta"
$pkgIgnores  = Get-PkgmetaIgnores $pkgmetaPath
$pkgPatterns = $pkgIgnores | ForEach-Object { Convert-GlobToRegex $_ }

# Also treat .pkgmeta itself as ignored (it's not in its own ignore list)
$pkgPatterns += '^\.pkgmeta$'

$SkipPatterns = $AlwaysSkip + $pkgPatterns

Write-Host ""
Write-Host "=== TOGTools Dev Sync ===" -ForegroundColor Magenta
if ($DryRun) {
    Write-Host "(DRY RUN -- no files will be copied or deleted)" -ForegroundColor Yellow
}
Write-Host "Source : $Source"                  -ForegroundColor White
foreach ($d in $Destinations) {
    Write-Host "Target : $d" -ForegroundColor Green
}
if ($pkgIgnores) {
    Write-Host ("Loaded {0} ignore globs from .pkgmeta:" -f $pkgIgnores.Count) -ForegroundColor DarkGray
    foreach ($g in $pkgIgnores) {
        Write-Host "  $g" -ForegroundColor DarkGray
    }
}
Write-Host "Press Ctrl+C to stop." -ForegroundColor White
Write-Host ""

function Skip-Path([string]$rel) {
    foreach ($p in $SkipPatterns) {
        if ($rel -match $p) { return $true }
    }
    return $false
}

function Sync-File([string]$fullPath, [string]$verb) {
    $rel = $fullPath.Substring($Source.Length).TrimStart('\','/')
    if (Skip-Path $rel) {
        if ($DryRun) {
            Write-Host "[skip] $rel" -ForegroundColor DarkGray
        }
        return
    }

    $ts = Get-Date -Format "HH:mm:ss"
    $tag = if ($DryRun) { "WOULD" } else { $verb }

    foreach ($dest in $Destinations) {
        $target = Join-Path $dest $rel
        if ($verb -eq "Deleted") {
            if (Test-Path $target -ErrorAction SilentlyContinue) {
                if (-not $DryRun) {
                    Remove-Item $target -Force -Recurse -ErrorAction SilentlyContinue
                }
                Write-Host "[$ts] $($tag.PadRight(7)) DEL $rel" -ForegroundColor Red
            }
        } else {
            $dir = Split-Path $target -Parent
            if (-not (Test-Path $dir) -and -not $DryRun) {
                New-Item -ItemType Directory -Path $dir -Force -ErrorAction SilentlyContinue | Out-Null
            }
            if (-not $DryRun) {
                # A locked target (WoW open + reading the file, AV scan, editor
                # write-replace in flight) makes Copy-Item throw. Don't let one
                # locked file kill the watcher -- log and let the next poll retry.
                # The file's lastWrite is recorded by the caller regardless, so a
                # transient failure self-heals on the user's next save of that file;
                # for the rarer "locked now, never touched again" case the initial
                # sync on next launch covers it.
                try {
                    Copy-Item $fullPath $target -Force -ErrorAction Stop
                    Write-Host "[$ts] $($tag.PadRight(7)) $rel" -ForegroundColor Cyan
                } catch {
                    Write-Host "[$ts] RETRY   $rel (target busy: $($_.Exception.Message))" -ForegroundColor DarkYellow
                }
            } else {
                Write-Host "[$ts] $($tag.PadRight(7)) $rel" -ForegroundColor Cyan
            }
        }
    }
}

# Full initial sync. Per-file try/catch so one unreadable/locked file (or a
# file that vanishes between enumeration and copy) can't abort the whole initial
# sync and exit the script before the watch loop ever starts -- that was one of
# the launch-time exit-4 crashes.
Write-Host "Initial sync..." -ForegroundColor Yellow
Get-ChildItem -Path $Source -Recurse -File -Force -ErrorAction SilentlyContinue | ForEach-Object {
    try { Sync-File $_.FullName "SYNC" }
    catch { Write-Host "[init] skipped $($_.Exception.Message)" -ForegroundColor DarkYellow }
}
Write-Host "Ready." -ForegroundColor Green
Write-Host ""

# In dry-run mode, exit after the initial sync. The whole point is "show me
# what would happen" -- there's no value in then sitting in a watcher loop.
if ($DryRun) {
    Write-Host "Dry run complete. Nothing was copied or deleted." -ForegroundColor Yellow
    exit 0
}

# Polling watcher -- scans the source tree every 2 seconds and syncs any file
# whose LastWriteTime advanced. Polling instead of Register-ObjectEvent because
# the event-action block runs in a separate runspace where this script's
# functions ($Source, $Destinations, Sync-File) are not in scope, so file
# changes were firing but silently doing nothing. Polling is slower (2 s
# latency) but reliable across PS5.1 / PS7 and across editor save patterns
# (some editors write-replace, which the FileSystemWatcher can mis-fire on).

$lastWrites = @{}
foreach ($f in Get-ChildItem -Path $Source -Recurse -File -Force) {
    $lastWrites[$f.FullName] = $f.LastWriteTimeUtc
}

Write-Host "Watching for changes (Ctrl+C to stop)..." -ForegroundColor Yellow
try {
    while ($true) {
        Start-Sleep -Seconds 2

        # Each poll iteration is wrapped so a transient I/O error never kills
        # the watcher. Real-world triggers seen in the field: Get-ChildItem
        # racing an editor's write-replace (a temp file enumerated then gone
        # mid-scan), a target file briefly locked, a directory vanishing during
        # recursion. Pre-fix, any of these threw out of the loop and the watcher
        # silently died (exit 4) -- the SessionStart hook then saw "already
        # launched" and never relaunched, so retail went stale. Now we log the
        # blip and the next 2 s poll just tries again.
        try {
            # Snapshot the tree once per poll so a mid-enumeration deletion
            # (editor swap-file, .git churn) can't throw partway through.
            $current = Get-ChildItem -Path $Source -Recurse -File -Force -ErrorAction SilentlyContinue

            # Created / Changed
            foreach ($f in $current) {
                $cur  = $f.LastWriteTimeUtc
                $prev = $lastWrites[$f.FullName]
                if ($null -eq $prev) {
                    Sync-File $f.FullName "Created"
                    $lastWrites[$f.FullName] = $cur
                } elseif ($cur -gt $prev) {
                    Sync-File $f.FullName "Changed"
                    $lastWrites[$f.FullName] = $cur
                }
            }

            # Deleted -- any path we knew about that no longer exists on disk.
            # Iterate a static copy of the keys so removing from $lastWrites
            # can't invalidate the enumerator.
            $toRemove = @()
            foreach ($k in @($lastWrites.Keys)) {
                if (-not (Test-Path -LiteralPath $k)) {
                    Sync-File $k "Deleted"
                    $toRemove += $k
                }
            }
            foreach ($k in $toRemove) { $lastWrites.Remove($k) }
        } catch {
            $ts = Get-Date -Format "HH:mm:ss"
            Write-Host "[$ts] WARN    poll skipped: $($_.Exception.Message)" -ForegroundColor DarkYellow
        }
    }
} finally {
    Write-Host "Watcher stopped." -ForegroundColor Yellow
    # Release the per-repo single-instance mutex so the next launch can start.
    if ($script:InstanceMutex) {
        try { $script:InstanceMutex.ReleaseMutex() } catch {}
        $script:InstanceMutex.Dispose()
    }
}
