tools/sync-supporters.ps1

# Rebuilds the tiered sections of SUPPORTERS.md from live GitHub Sponsors data.
#
# Preservation guarantees:
# - Conductor's Circle: entries are PERMANENT (Founding rule). Cancellations,
# refunds, downgrades - nothing removes them automatically. Manual only.
# - Encore/Applause: entries tied to GitHub sponsorships follow the API -
# cancellations and refunds drop them. Manual entries (lines without a
# github.com link, e.g. bank-transfer backers) are preserved untouched.
#
# One-time payments: surfaced for manual review (printed as a notice) because
# the API does not reliably expose their amount - run add-supporter.ps1 for
# whoever qualifies after eyeballing the payment.
#
# Usage:
# ./tools/sync-supporters.ps1 # live sync
# ./tools/sync-supporters.ps1 -DryRun # preview resulting file
# ./tools/sync-supporters.ps1 -MockActivitiesJson '<json>' -MockSponsorshipsJson '<json>'
param(
    [string]$Login = 'abmach',
    [string]$Token = $env:MAESTRO_GH_TOKEN,
    [string]$Root,
    [string]$MockActivitiesJson,
    [string]$MockSponsorshipsJson,
    [switch]$DryRun
)

$ErrorActionPreference = 'Stop'
if (-not $MockActivitiesJson -and -not $Token) {
    throw "Set MAESTRO_GH_TOKEN (a GitHub PAT with read:user scope) to sync supporters."
}
if (-not $Root) { $Root = Split-Path -Parent $PSScriptRoot }
$file = Join-Path $Root 'SUPPORTERS.md'
if (-not (Test-Path -LiteralPath $file)) { throw "SUPPORTERS.md not found at $file" }

$query = @'
query($login:String!){
  user(login:$login){
    sponsorsActivities(first:100,order:{field:TIMESTAMP,direction:DESC}){
      nodes{
        action
        sponsor{ login }
        sponsorsTier{ monthlyPriceInDollars }
        previousSponsorsTier{ monthlyPriceInDollars }
      }
    }
    sponsorshipsAsMaintainer(first:100){
      nodes{
        sponsor{ login }
        tier{ monthlyPriceInDollars }
        isOneTimePayment
      }
    }
  }
}
'@


$activities = @()
$sponsorships = @()

if ($MockActivitiesJson) {
    Write-Host 'using mocked data' -ForegroundColor Yellow
    $mock = @{ data = @{ user = @{
        sponsorsActivities       = @{ nodes = ($MockActivitiesJson | ConvertFrom-Json) }
        sponsorshipsAsMaintainer = @{ nodes = if ($MockSponsorshipsJson) { ($MockSponsorshipsJson | ConvertFrom-Json) } else { @() } }
    } } }
    $activities = $mock.data.user.sponsorsActivities.nodes
    $sponsorships = $mock.data.user.sponsorshipsAsMaintainer.nodes
} else {
    $body = @{ query = $query; variables = @{ login = $Login } } | ConvertTo-Json -Depth 6
    $response = Invoke-RestMethod -Uri 'https://api.github.com/graphql' -Method Post `
        -Headers @{ Authorization = "Bearer $Token"; 'User-Agent' = 'maestro-sync' } `
        -Body $body -ContentType 'application/json'
    $activities = $response.data.user.sponsorsActivities.nodes
    $sponsorships = $response.data.user.sponsorshipsAsMaintainer.nodes
}

# --- derive current state ---
$state = @{}
$pendingManual = New-Object System.Collections.Generic.List[string]

foreach ($s in $sponsorships) {
    $login = $s.sponsor.login
    if (-not $state.ContainsKey($login)) {
        $state[$login] = @{ amount = 0; active = $true }
    }
    if ($s.isOneTimePayment) {
        # One-time payment: amount not exposed via tier - flag for manual review.
        $pendingManual.Add("one-time payment from @$login (amount unknown via API)")
        continue
    }
    $amt = 0
    if ($s.tier -and $s.tier.monthlyPriceInDollars) { $amt = [int]$s.tier.monthlyPriceInDollars }
    $state[$login].amount = [Math]::Max($state[$login].amount, $amt)
}

foreach ($a in $activities) {
    $login = $a.sponsor.login
    if (-not $state.ContainsKey($login)) {
        $state[$login] = @{ amount = 0; active = $false }
    }
    switch ($a.action) {
        'NEW_SPONSORSHIP'          { if ($a.sponsorsTier -and $a.sponsorsTier.monthlyPriceInDollars) { $state[$login].amount = [Math]::Max($state[$login].amount, [int]$a.sponsorsTier.monthlyPriceInDollars); $state[$login].active = $true } }
        'TIER_CHANGE'              { if ($a.sponsorsTier -and $a.sponsorsTier.monthlyPriceInDollars) { $state[$login].amount = [Math]::Max($state[$login].amount, [int]$a.sponsorsTier.monthlyPriceInDollars) } }
        'CANCELLED_SPONSORSHIP'    { $state[$login].active = $false }
        'REFUND'                   { $state[$login].active = $false }
        default                    { }   # PENDING_CHANGE / SPONSOR_MATCH_DISABLED ignored
    }
}

function Get-Bucket([int]$amount) {
    if ($amount -ge 25) { 'Conductor' } elseif ($amount -ge 10) { 'Encore' } else { 'Applause' }
}

# --- parse existing SUPPORTERS.md (preservation source) ---
function Get-EntryLogin([string]$line) {
    $m = [regex]::Match($line, 'github\.com/([^/\)\s]+)')
    if ($m.Success) { return $m.Groups[1].Value }
    return $null
}

$sectionOrder = @('Conductor', 'Encore', 'Applause')
$headers = @{
    Conductor = '## 💎 Conductor''s Circle'
    Encore    = '## 🎭 Encore'
    Applause  = '## 👏 Applause'
}

$lines = [System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8)

$firstTierIdx = $null
for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^## 💎') { $firstTierIdx = $i; break } }
if (-not $firstTierIdx) { throw 'tier headings not found in SUPPORTERS.md' }

$intro = $lines[0..($firstTierIdx - 1)]
$preserved = @{}
$currentTier = $null
for ($i = $firstTierIdx; $i -lt $lines.Count; $i++) {
    $l = $lines[$i]
    if ($l -match '^## 💎') { $currentTier = 'Conductor'; continue }
    if ($l -match '^## 🎭') { $currentTier = 'Encore';    continue }
    if ($l -match '^## 👏') { $currentTier = 'Applause';  continue }
    if ($l -match '^## ')  { break }
    if ($currentTier -and $l.Trim()) {
        if (-not $preserved.ContainsKey($currentTier)) { $preserved[$currentTier] = New-Object System.Collections.Generic.List[string] }
        $preserved[$currentTier].Add($l)
    }
}

# --- merge with guarantees ---
$final = @{}
foreach ($t in $sectionOrder) {
    $final[$t] = New-Object System.Collections.Generic.List[string]
    if ($preserved.ContainsKey($t)) { foreach ($e in $preserved[$t]) { $final[$t].Add($e) } }
}

# Removals: inactive GitHub-channel sponsors drop from Encore/Applause only.
# Conductor's Circle is intentionally untouched here - Founding permanence
# means cancellations/refunds NEVER auto-remove conductors (manual only).
foreach ($t in @('Encore', 'Applause')) {
    for ($i = $final[$t].Count - 1; $i -ge 0; $i--) {
        $lg = Get-EntryLogin $final[$t][$i]
        if ($lg -and $state.ContainsKey($lg) -and (-not $state[$lg].active)) {
            Write-Host ("dropping inactive sponsor from " + $t + ": @" + $lg) -ForegroundColor DarkGray
            $final[$t].RemoveAt($i)
        }
    }
}

foreach ($login in $state.Keys) {
    $st = $state[$login]
    if (-not $st.active) { continue }   # cancelled/refunded -> drops from Encore/Applause;
                                        # Conductor entries preserved unconditionally above
    $bucket = Get-Bucket $st.amount
    $already = $false
    foreach ($t in $sectionOrder) {
        for ($i = 0; $i -lt $final[$t].Count; $i++) {
            if ((Get-EntryLogin $final[$t][$i]) -eq $login) {
                if ($t -ne $bucket) {
                    $final[$t].RemoveAt($i); $i--
                    $final[$bucket].Add('- [@' + $login + '](https://github.com/' + $login + ') - $' + $st.amount + '/mo')
                }
                $already = $true; break
            }
        }
        if ($already) { break }
    }
    if (-not $already) {
        $final[$bucket].Add('- [@' + $login + '](https://github.com/' + $login + ') - $' + $st.amount + '/mo')
    }
}

# --- assemble ---
$newLines = New-Object System.Collections.Generic.List[string]
foreach ($l in $intro) { $newLines.Add($l) }
foreach ($t in $sectionOrder) {
    $newLines.Add('')
    $newLines.Add($headers[$t])
    foreach ($e in $final[$t]) { $newLines.Add(' ' + $e) }
    if ($final[$t].Count -eq 0) { $newLines.Add(' _No entries yet._') }
}
$newContent = ($newLines -join "`n") + "`n"

$total = 0
foreach ($t in $sectionOrder) { $total += $final[$t].Count }

if ($DryRun) {
    Write-Host 'DRY RUN - resulting SUPPORTERS.md:' -ForegroundColor Yellow
    Write-Output $newContent
} else {
    [System.IO.File]::WriteAllText($file, $newContent, (New-Object System.Text.UTF8Encoding $false))
    Write-Host ("synced: $total active entr(y/ies) - conductors preserved permanently") -ForegroundColor Green
    Write-Host 'next: review diff -> commit & push via GitLab (mirror carries it)'
}