Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

#region ===== Shared Helpers =====

function Write-Log {
    param([string]$Message, [string]$Color = "LightGreen")
    if ($script:LogBox -and !$script:LogBox.IsDisposed) {
        $script:LogBox.SelectionStart  = $script:LogBox.TextLength
        $script:LogBox.SelectionLength = 0
        $script:LogBox.SelectionColor  = [System.Drawing.Color]::$Color
        $script:LogBox.AppendText("$(Get-Date -Format 'HH:mm:ss') - $Message`n")
        $script:LogBox.ScrollToCaret()
        [System.Windows.Forms.Application]::DoEvents()
    }
    Write-Host $Message
}

function EscHtml([string]$s) {
    return $s -replace '&','&amp;' -replace '<','&lt;' -replace '>','&gt;' -replace '"','&quot;'
}

function Format-Bytes([long]$b) {
    if ($b -ge 1GB) { return "$([math]::Round($b/1GB,2)) GB" }
    if ($b -ge 1MB) { return "$([math]::Round($b/1MB,1)) MB" }
    if ($b -ge 1KB) { return "$([math]::Round($b/1KB,0)) KB" }
    return "$b B"
}

function Validate-Inputs {
    if ([string]::IsNullOrWhiteSpace($script:txtClientId.Text)) {
        $msg = T "validate.missing.clientid.hint"
        [System.Windows.Forms.MessageBox]::Show($msg, (T "validate.missing.title"), "OK", "Warning")
        return $false
    }
    $hasSites = ($script:SelectedSites -and $script:SelectedSites.Count -gt 0)
    if (-not $hasSites -and [string]::IsNullOrWhiteSpace($script:txtSiteURL.Text)) {
        [System.Windows.Forms.MessageBox]::Show(
            "Please enter a Site URL or select sites via 'Voir les sites'.",
            "Missing Field", "OK", "Warning")
        return $false
    }
    return $true
}

#endregion

#region ===== Profile Management =====

function Get-ProfilesFilePath {
    $dir = if ($script:DataFolder)  { $script:DataFolder }
           elseif ($PSScriptRoot)   { $PSScriptRoot }
           else                     { $PWD.Path }
    return Join-Path $dir "Sharepoint_Export_profiles.json"
}

function Load-Profiles {
    $path = Get-ProfilesFilePath
    if (Test-Path $path) {
        try {
            $data = Get-Content $path -Raw | ConvertFrom-Json
            if ($data.profiles) { return @($data.profiles) }
        } catch {}
    }
    return @()
}

function Save-Profiles {
    param([array]$Profiles)
    $path = Get-ProfilesFilePath
    @{ profiles = @($Profiles) } | ConvertTo-Json -Depth 5 | Set-Content $path -Encoding UTF8
}

function Show-InputDialog {
    param(
        [string]$Prompt,
        [string]$Title,
        [string]$Default = "",
        [System.Windows.Forms.Form]$Owner = $null
    )
    $dlg = New-Object System.Windows.Forms.Form
    $dlg.Text = $Title
    $dlg.Size = New-Object System.Drawing.Size(380, 140)
    $dlg.StartPosition = "CenterParent"
    $dlg.FormBorderStyle = "FixedDialog"
    $dlg.MaximizeBox = $false
    $dlg.MinimizeBox = $false
    $dlg.BackColor = [System.Drawing.Color]::WhiteSmoke
    $lbl = New-Object System.Windows.Forms.Label
    $lbl.Text = $Prompt; $lbl.Location = New-Object System.Drawing.Point(10,10)
    $lbl.Size = New-Object System.Drawing.Size(340,20)
    $txt = New-Object System.Windows.Forms.TextBox
    $txt.Text = $Default; $txt.Location = New-Object System.Drawing.Point(10,36)
    $txt.Size = New-Object System.Drawing.Size(340,22)
    $btnOK = New-Object System.Windows.Forms.Button
    $btnOK.Text = "OK"; $btnOK.Location = New-Object System.Drawing.Point(152,70)
    $btnOK.Size = New-Object System.Drawing.Size(80,26); $btnOK.DialogResult = "OK"
    $dlg.AcceptButton = $btnOK
    $btnCancel = New-Object System.Windows.Forms.Button
    $btnCancel.Text = "Annuler"; $btnCancel.Location = New-Object System.Drawing.Point(246,70)
    $btnCancel.Size = New-Object System.Drawing.Size(104,26); $btnCancel.DialogResult = "Cancel"
    $dlg.CancelButton = $btnCancel
    $dlg.Controls.AddRange(@($lbl, $txt, $btnOK, $btnCancel))
    $result = if ($Owner) { $dlg.ShowDialog($Owner) } else { $dlg.ShowDialog() }
    if ($result -eq "OK") { return $txt.Text.Trim() }
    return $null
}

function Refresh-ProfileList {
    $script:Profiles = Load-Profiles
    $script:cboProfile.Items.Clear()
    foreach ($p in $script:Profiles) { [void]$script:cboProfile.Items.Add($p.name) }
    if ($script:cboProfile.Items.Count -gt 0) { $script:cboProfile.SelectedIndex = 0 }
}

function Apply-Profile {
    param([int]$idx)
    if ($idx -lt 0 -or $idx -ge $script:Profiles.Count) { return }
    $p = $script:Profiles[$idx]
    if ($p.clientId)   { $script:txtClientId.Text   = $p.clientId }
    if ($p.tenantUrl)  { $script:txtTenantUrl.Text  = $p.tenantUrl }
    # Reset site selection when switching profile
    $script:SelectedSites = @()
    $script:btnBrowseSites.Text = "Voir les sites"
}

#endregion

#region ===== Settings =====

function Get-SettingsFilePath {
    $dir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
    return Join-Path $dir "Sharepoint_Settings.json"
}

function Load-Settings {
    $path = Get-SettingsFilePath
    if (Test-Path $path) {
        try {
            $data = Get-Content $path -Raw | ConvertFrom-Json
            return $data
        } catch {}
    }
    return [PSCustomObject]@{ dataFolder = ""; lang = "en" }
}

function Save-Settings {
    param([string]$DataFolder, [string]$Lang = "en")
    $path = Get-SettingsFilePath
    [PSCustomObject]@{ dataFolder = $DataFolder; lang = $Lang } |
        ConvertTo-Json | Set-Content $path -Encoding UTF8
}

#endregion

#region ===== Site Picker =====

# All state in $script:_pkl; accessible from any event handler (no closure tricks needed)

function _Pkl-FormatMB([long]$mb) {
    if ($mb -ge 1024) { return "$([math]::Round($mb / 1024, 1)) GB" }
    if ($mb -gt 0)    { return "$mb MB" }
    return "-"
}

function _Pkl-Sort {
    $col  = $script:_pkl.SortCol
    $desc = -not $script:_pkl.SortAsc
    $script:_pkl.AllSites = @(switch ($col) {
        0 { $script:_pkl.AllSites | Sort-Object -Property Title -Descending:$desc }
        1 { $script:_pkl.AllSites | Sort-Object -Property { [int][bool]$_.IsTeamsConnected } -Descending:$desc }
        2 { $script:_pkl.AllSites | Sort-Object -Property { [long]$_.StorageUsageCurrent }   -Descending:$desc }
        default { $script:_pkl.AllSites | Sort-Object -Property Title -Descending:$desc }
    })
    $lv  = $script:_pkl.Lv
    $dir = if (-not $desc) { " ^" } else { " v" }
    $script:_pkl.ColNames | ForEach-Object -Begin { $i = 0 } -Process {
        $lv.Columns[$i].Text = $_ + $(if ($i -eq $col) { $dir } else { "" })
        $i++
    }
}

function _Pkl-Repopulate {
    $filter = $script:_pkl.TxtFilter.Text.ToLower()
    $lv     = $script:_pkl.Lv
    $script:_pkl.SuppressCheck = $true
    $lv.BeginUpdate()
    $lv.Items.Clear()
    $visible = if ($filter) {
        $script:_pkl.AllSites | Where-Object {
            $_.Title.ToLower().Contains($filter) -or $_.Url.ToLower().Contains($filter)
        }
    } else { $script:_pkl.AllSites }
    foreach ($s in $visible) {
        $teams = if ($s.IsTeamsConnected) { "Oui" } else { "Non" }
        $stor  = _Pkl-FormatMB ([long]$s.StorageUsageCurrent)
        $item  = New-Object System.Windows.Forms.ListViewItem($s.Title)
        $item.Tag = $s.Url
        [void]$item.SubItems.Add($teams)
        [void]$item.SubItems.Add($stor)
        [void]$item.SubItems.Add($s.Url)
        [void]$lv.Items.Add($item)
        $item.Checked = $script:_pkl.CheckedUrls.Contains($s.Url)
    }
    $lv.EndUpdate()
    $script:_pkl.SuppressCheck = $false
    $script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s)  --  " +
        "$($lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
    $script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::Gray
}

function Show-SitePicker {
    param(
        [string]$TenantUrl,
        [string]$ClientId,
        [System.Windows.Forms.Form]$Owner   = $null,
        [object[]]$InitialSites             = @(),
        [string[]]$PreSelected              = @()
    )

    $dlg                 = New-Object System.Windows.Forms.Form
    $dlg.Text            = "Sites SharePoint -- $TenantUrl"
    $dlg.Size            = New-Object System.Drawing.Size(900, 580)
    $dlg.StartPosition   = "CenterParent"
    $dlg.FormBorderStyle = "Sizable"
    $dlg.MinimumSize     = New-Object System.Drawing.Size(600, 440)
    $dlg.BackColor       = [System.Drawing.Color]::WhiteSmoke

    # -- Top bar --
    $lblFilter           = New-Object System.Windows.Forms.Label
    $lblFilter.Text      = "Filtrer :"
    $lblFilter.Location  = New-Object System.Drawing.Point(10, 12)
    $lblFilter.Size      = New-Object System.Drawing.Size(52, 22)
    $lblFilter.TextAlign = "MiddleLeft"

    $txtFilter           = New-Object System.Windows.Forms.TextBox
    $txtFilter.Location  = New-Object System.Drawing.Point(66, 10)
    $txtFilter.Size      = New-Object System.Drawing.Size(570, 22)
    $txtFilter.Font      = New-Object System.Drawing.Font("Segoe UI", 9)
    $txtFilter.Anchor    = [System.Windows.Forms.AnchorStyles]"Top,Left,Right"

    $btnLoad             = New-Object System.Windows.Forms.Button
    $btnLoad.Text        = "Charger les sites"
    $btnLoad.Location    = New-Object System.Drawing.Point(648, 8)
    $btnLoad.Size        = New-Object System.Drawing.Size(148, 26)
    $btnLoad.Anchor      = [System.Windows.Forms.AnchorStyles]"Top,Right"
    $btnLoad.BackColor   = [System.Drawing.Color]::SteelBlue
    $btnLoad.ForeColor   = [System.Drawing.Color]::White
    $btnLoad.FlatStyle   = "Flat"
    $btnLoad.Font        = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)

    # -- Site list (ListView with columns) --
    $lv                       = New-Object System.Windows.Forms.ListView
    $lv.Location              = New-Object System.Drawing.Point(10, 44)
    $lv.Size                  = New-Object System.Drawing.Size(864, 400)
    $lv.Anchor                = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
    $lv.View                  = [System.Windows.Forms.View]::Details
    $lv.CheckBoxes            = $true
    $lv.FullRowSelect         = $true
    $lv.GridLines             = $true
    $lv.Font                  = New-Object System.Drawing.Font("Segoe UI", 9)
    $lv.HeaderStyle           = [System.Windows.Forms.ColumnHeaderStyle]::Clickable

    [void]$lv.Columns.Add("Nom",              380)
    [void]$lv.Columns.Add("Equipe Teams",      90)
    [void]$lv.Columns.Add("Stockage",         100)
    [void]$lv.Columns.Add("URL",              280)

    # -- Status bar --
    $lblStatus           = New-Object System.Windows.Forms.Label
    $lblStatus.Text      = "Cliquez sur 'Charger les sites' pour recuperer la liste du tenant."
    $lblStatus.Location  = New-Object System.Drawing.Point(10, 456)
    $lblStatus.Size      = New-Object System.Drawing.Size(860, 18)
    $lblStatus.Anchor    = [System.Windows.Forms.AnchorStyles]"Bottom,Left,Right"
    $lblStatus.ForeColor = [System.Drawing.Color]::Gray
    $lblStatus.Font      = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)

    # -- Bottom buttons --
    $btnSelAll           = New-Object System.Windows.Forms.Button
    $btnSelAll.Text      = "Tout selectionner"
    $btnSelAll.Location  = New-Object System.Drawing.Point(10, 484)
    $btnSelAll.Size      = New-Object System.Drawing.Size(130, 26)
    $btnSelAll.Anchor    = [System.Windows.Forms.AnchorStyles]"Bottom,Left"

    $btnSelNone          = New-Object System.Windows.Forms.Button
    $btnSelNone.Text     = "Tout decocher"
    $btnSelNone.Location = New-Object System.Drawing.Point(148, 484)
    $btnSelNone.Size     = New-Object System.Drawing.Size(110, 26)
    $btnSelNone.Anchor   = [System.Windows.Forms.AnchorStyles]"Bottom,Left"

    $btnOK               = New-Object System.Windows.Forms.Button
    $btnOK.Text          = "OK"
    $btnOK.Location      = New-Object System.Drawing.Point(694, 484)
    $btnOK.Size          = New-Object System.Drawing.Size(90, 26)
    $btnOK.Anchor        = [System.Windows.Forms.AnchorStyles]"Bottom,Right"
    $btnOK.DialogResult  = "OK"
    $btnOK.BackColor     = [System.Drawing.Color]::SteelBlue
    $btnOK.ForeColor     = [System.Drawing.Color]::White
    $btnOK.FlatStyle     = "Flat"
    $btnOK.Enabled       = $false

    $btnDlgCancel              = New-Object System.Windows.Forms.Button
    $btnDlgCancel.Text         = "Annuler"
    $btnDlgCancel.Location     = New-Object System.Drawing.Point(794, 484)
    $btnDlgCancel.Size         = New-Object System.Drawing.Size(90, 26)
    $btnDlgCancel.Anchor       = [System.Windows.Forms.AnchorStyles]"Bottom,Right"
    $btnDlgCancel.DialogResult = "Cancel"

    $dlg.AcceptButton = $btnOK
    $dlg.CancelButton = $btnDlgCancel
    $dlg.Controls.AddRange(@($lblFilter, $txtFilter, $btnLoad, $lv, $lblStatus,
                              $btnSelAll, $btnSelNone, $btnOK, $btnDlgCancel))

    # Init script-scope state (modal dialog - no concurrency issue)
    $script:_pkl = @{
        AllSites      = @()
        CheckedUrls   = [System.Collections.Generic.HashSet[string]]::new(
                            [System.StringComparer]::OrdinalIgnoreCase)
        Lv            = $lv
        TxtFilter     = $txtFilter
        LblStatus     = $lblStatus
        BtnOK         = $btnOK
        BtnLoad       = $btnLoad
        SuppressCheck = $false
        SortCol       = 0
        SortAsc       = $true
        ColNames      = @("Nom", "Equipe Teams", "Stockage", "URL")
        Sync          = $null
        Timer         = $null
        RS            = $null
        PS            = $null
        Hnd           = $null
        AdminUrl      = if ($TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') {
                            "$($Matches[1])-admin$($Matches[2])"
                        } else { $null }
        ClientId      = $ClientId
    }

    # Pre-populate from cache + restore previous selection
    if ($InitialSites -and $InitialSites.Count -gt 0) {
        $script:_pkl.AllSites = @($InitialSites)
        foreach ($url in $PreSelected) { [void]$script:_pkl.CheckedUrls.Add($url) }
        $btnLoad.Text = "Recharger"
        _Pkl-Sort
        _Pkl-Repopulate
        $script:_pkl.BtnOK.Enabled = $true
    }

    # ItemChecked fires AFTER the checked state changes (unlike ItemCheck)
    $lv.Add_ItemChecked({
        param($s, $e)
        if ($script:_pkl.SuppressCheck) { return }
        $url = $e.Item.Tag
        if ($e.Item.Checked) { [void]$script:_pkl.CheckedUrls.Add($url)    }
        else                 { [void]$script:_pkl.CheckedUrls.Remove($url) }
        $script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s)  --  " +
            "$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
    })

    $lv.Add_ColumnClick({
        param($s, $e)
        if ($script:_pkl.SortCol -eq $e.Column) {
            $script:_pkl.SortAsc = -not $script:_pkl.SortAsc
        } else {
            $script:_pkl.SortCol = $e.Column
            $script:_pkl.SortAsc = $true
        }
        _Pkl-Sort
        _Pkl-Repopulate
    })

    $txtFilter.Add_TextChanged({ _Pkl-Repopulate })

    $btnSelAll.Add_Click({
        $script:_pkl.CheckedUrls.Clear()
        $script:_pkl.SuppressCheck = $true
        foreach ($item in $script:_pkl.Lv.Items) {
            $item.Checked = $true
            [void]$script:_pkl.CheckedUrls.Add($item.Tag)
        }
        $script:_pkl.SuppressCheck = $false
        $script:_pkl.LblStatus.Text = "$($script:_pkl.CheckedUrls.Count) coche(s)  --  " +
            "$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
    })

    $btnSelNone.Add_Click({
        $script:_pkl.CheckedUrls.Clear()
        $script:_pkl.SuppressCheck = $true
        foreach ($item in $script:_pkl.Lv.Items) { $item.Checked = $false }
        $script:_pkl.SuppressCheck = $false
        $script:_pkl.LblStatus.Text = "0 coche(s)  --  " +
            "$($script:_pkl.Lv.Items.Count) affiche(s) sur $($script:_pkl.AllSites.Count)"
    })

    $btnLoad.Add_Click({
        if (-not $script:_pkl.AdminUrl) {
            [System.Windows.Forms.MessageBox]::Show(
                "URL Tenant invalide (attendu: https://xxx.sharepoint.com).",
                "Erreur", "OK", "Error")
            return
        }
        $script:_pkl.BtnLoad.Enabled     = $false
        $script:_pkl.BtnOK.Enabled       = $false
        $script:_pkl.Lv.Items.Clear()
        $script:_pkl.AllSites            = @()
        $script:_pkl.LblStatus.Text      = "Connexion a $($script:_pkl.AdminUrl) ..."
        $script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::DarkOrange

        $sync = [hashtable]::Synchronized(@{ Done = $false; Error = $null; Sites = $null })
        $script:_pkl.Sync = $sync

        $bgFetch = {
            param($AdminUrl, $ClientId, $Sync)
            try {
                Import-Module PnP.PowerShell -ErrorAction Stop
                Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId
                $Sync.Sites = @(
                    Get-PnPTenantSite |
                        Select-Object Title, Url, IsTeamsConnected, StorageUsageCurrent |
                        Sort-Object Title
                )
            } catch { $Sync.Error = $_.Exception.Message }
            finally  { $Sync.Done = $true }
        }

        $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
        $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
        $ps = [System.Management.Automation.PowerShell]::Create()
        $ps.Runspace = $rs
        [void]$ps.AddScript($bgFetch)
        [void]$ps.AddArgument($script:_pkl.AdminUrl)
        [void]$ps.AddArgument($script:_pkl.ClientId)
        [void]$ps.AddArgument($sync)
        $script:_pkl.RS  = $rs
        $script:_pkl.PS  = $ps
        $script:_pkl.Hnd = $ps.BeginInvoke()

        $tmr          = New-Object System.Windows.Forms.Timer
        $tmr.Interval = 300
        $script:_pkl.Timer = $tmr

        $tmr.Add_Tick({
            if ($script:_pkl.Sync.Done) {
                $script:_pkl.Timer.Stop(); $script:_pkl.Timer.Dispose()
                try { [void]$script:_pkl.PS.EndInvoke($script:_pkl.Hnd) } catch {}
                try { $script:_pkl.RS.Close(); $script:_pkl.RS.Dispose() } catch {}
                $script:_pkl.BtnLoad.Enabled = $true
                if ($script:_pkl.Sync.Error) {
                    $script:_pkl.LblStatus.Text      = "Erreur: $($script:_pkl.Sync.Error)"
                    $script:_pkl.LblStatus.ForeColor = [System.Drawing.Color]::Red
                } else {
                    $script:_pkl.AllSites = @($script:_pkl.Sync.Sites)
                    $script:_SiteCache    = @($script:_pkl.Sync.Sites)
                    _Pkl-Sort
                    _Pkl-Repopulate
                    $script:_pkl.BtnLoad.Text  = "Recharger"
                    $script:_pkl.BtnOK.Enabled = ($script:_pkl.Lv.Items.Count -gt 0)
                }
            } else {
                $dot = "." * (([System.DateTime]::Now.Second % 4) + 1)
                $script:_pkl.LblStatus.Text = "Chargement$dot"
            }
        })
        $tmr.Start()
    })

    $result = if ($Owner) { $dlg.ShowDialog($Owner) } else { $dlg.ShowDialog() }

    if ($result -eq "OK") { return ,@($script:_pkl.CheckedUrls) }
    return $null
}

#endregion

#region ===== Template Management =====

function Get-TemplatesFilePath {
    $dir = if ($script:DataFolder)  { $script:DataFolder }
           elseif ($PSScriptRoot)   { $PSScriptRoot }
           else                     { $PWD.Path }
    return Join-Path $dir "Sharepoint_Templates.json"
}

function Load-Templates {
    $path = Get-TemplatesFilePath
    if (Test-Path $path) {
        try {
            $data = Get-Content $path -Raw | ConvertFrom-Json
            if ($data.templates) { return @($data.templates) }
        } catch {}
    }
    return @()
}

function Save-Templates {
    param([array]$Templates)
    $path = Get-TemplatesFilePath
    @{ templates = @($Templates) } | ConvertTo-Json -Depth 20 | Set-Content $path -Encoding UTF8
}

# Script-scope helpers (accessible from all event handlers - no closure tricks)
function _Tpl-Repopulate {
    $lv = $script:_tpl.Lv
    $lv.BeginUpdate()
    $lv.Items.Clear()
    foreach ($t in $script:_tpl.Templates) {
        $opts = @()
        if ($t.options.structure)   { $opts += "Arborescence" }
        if ($t.options.permissions) { $opts += "Permissions" }
        if ($t.options.settings)    { $opts += "Parametres" }
        if ($t.options.style)       { $opts += "Style" }
        $item = New-Object System.Windows.Forms.ListViewItem($t.name)
        $item.Tag = $t
        $srcText = if ($t.sourceUrl)  { $t.sourceUrl  } else { "" }
        $datText = if ($t.capturedAt) { $t.capturedAt } else { "" }
        [void]$item.SubItems.Add($srcText)
        [void]$item.SubItems.Add($datText)
        [void]$item.SubItems.Add(($opts -join ", "))
        [void]$lv.Items.Add($item)
    }
    $lv.EndUpdate()
    $script:_tpl.BtnDelete.Enabled = $false
    $script:_tpl.BtnCreate.Enabled = $false
}

function _Tpl-RefreshCombo {
    $cbo = $script:_tpl.CboCrTpl
    $cbo.Items.Clear()
    foreach ($t in $script:_tpl.Templates) { [void]$cbo.Items.Add($t.name) }
    if ($cbo.Items.Count -gt 0) { $cbo.SelectedIndex = 0 }
}

function _Tpl-Log {
    param([System.Windows.Forms.RichTextBox]$Box, [string]$Msg, [string]$Color = "LightGreen")
    $Box.SelectionStart  = $Box.TextLength
    $Box.SelectionLength = 0
    $Box.SelectionColor  = [System.Drawing.Color]::$Color
    $Box.AppendText("$(Get-Date -Format 'HH:mm:ss') - $Msg`n")
    $Box.ScrollToCaret()
}

function Show-TemplateManager {
    param(
        [string]$DefaultSiteUrl = "",
        [string]$ClientId       = "",
        [string]$TenantUrl      = "",
        [System.Windows.Forms.Form]$Owner = $null
    )

    $dlg                 = New-Object System.Windows.Forms.Form
    $dlg.Text            = "Gestionnaire de Templates SharePoint"
    $dlg.Size            = New-Object System.Drawing.Size(980, 700)
    $dlg.StartPosition   = "CenterParent"
    $dlg.FormBorderStyle = "Sizable"
    $dlg.MinimumSize     = New-Object System.Drawing.Size(700, 560)
    $dlg.BackColor       = [System.Drawing.Color]::WhiteSmoke

    # ── Template list ──────────────────────────────────────────────────────
    $grpList             = New-Object System.Windows.Forms.GroupBox
    $grpList.Text        = "Templates enregistres"
    $grpList.Location    = New-Object System.Drawing.Point(10, 8)
    $grpList.Size        = New-Object System.Drawing.Size(950, 174)
    $grpList.Anchor      = [System.Windows.Forms.AnchorStyles]"Top,Left,Right"
    $grpList.Font        = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)

    $lv                  = New-Object System.Windows.Forms.ListView
    $lv.Location         = New-Object System.Drawing.Point(8, 22)
    $lv.Size             = New-Object System.Drawing.Size(820, 138)
    $lv.Anchor           = [System.Windows.Forms.AnchorStyles]"Top,Left,Right"
    $lv.View             = [System.Windows.Forms.View]::Details
    $lv.FullRowSelect    = $true
    $lv.GridLines        = $true
    $lv.MultiSelect      = $false
    $lv.Font             = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Regular)
    [void]$lv.Columns.Add("Nom",              200)
    [void]$lv.Columns.Add("Site source",      280)
    [void]$lv.Columns.Add("Date",             120)
    [void]$lv.Columns.Add("Options capturees", 200)

    $btnDelete           = New-Object System.Windows.Forms.Button
    $btnDelete.Text      = "Supprimer"
    $btnDelete.Location  = New-Object System.Drawing.Point(836, 22)
    $btnDelete.Size      = New-Object System.Drawing.Size(106, 26)
    $btnDelete.Anchor    = [System.Windows.Forms.AnchorStyles]"Top,Right"
    $btnDelete.Enabled   = $false

    $btnCreate           = New-Object System.Windows.Forms.Button
    $btnCreate.Text      = "Creer depuis ce template >"
    $btnCreate.Location  = New-Object System.Drawing.Point(836, 56)
    $btnCreate.Size      = New-Object System.Drawing.Size(106, 42)
    $btnCreate.Anchor    = [System.Windows.Forms.AnchorStyles]"Top,Right"
    $btnCreate.Enabled   = $false
    $btnCreate.BackColor = [System.Drawing.Color]::SteelBlue
    $btnCreate.ForeColor = [System.Drawing.Color]::White
    $btnCreate.FlatStyle = "Flat"
    $btnCreate.Font      = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Bold)

    $grpList.Controls.AddRange(@($lv, $btnDelete, $btnCreate))

    # ── Bottom tabs ────────────────────────────────────────────────────────
    $btabs               = New-Object System.Windows.Forms.TabControl
    $btabs.Location      = New-Object System.Drawing.Point(10, 190)
    $btabs.Size          = New-Object System.Drawing.Size(950, 464)
    $btabs.Anchor        = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
    $btabs.Font          = New-Object System.Drawing.Font("Segoe UI", 9)

    # ── Tab: Capturer ──────────────────────────────────────────────────────
    $tabCap              = New-Object System.Windows.Forms.TabPage
    $tabCap.Text         = "  Capturer un template  "
    $tabCap.BackColor    = [System.Drawing.Color]::WhiteSmoke

    $mkLbl = {
        param($t,$x,$y,$w=110)
        $l = New-Object System.Windows.Forms.Label
        $l.Text = $t; $l.Location = New-Object System.Drawing.Point($x,$y)
        $l.Size = New-Object System.Drawing.Size($w,22); $l.TextAlign = "MiddleLeft"; $l
    }

    $lblCapSrc           = & $mkLbl "Site source :" 10 18
    $txtCapSrc           = New-Object System.Windows.Forms.TextBox
    $txtCapSrc.Location  = New-Object System.Drawing.Point(128, 18)
    $txtCapSrc.Size      = New-Object System.Drawing.Size(560, 22)
    $txtCapSrc.Text      = $DefaultSiteUrl
    $txtCapSrc.Font      = New-Object System.Drawing.Font("Consolas", 9)

    $lblCapName          = & $mkLbl "Nom du template :" 10 48 120
    $txtCapName          = New-Object System.Windows.Forms.TextBox
    $txtCapName.Location = New-Object System.Drawing.Point(134, 48)
    $txtCapName.Size     = New-Object System.Drawing.Size(300, 22)
    $txtCapName.Font     = New-Object System.Drawing.Font("Segoe UI", 9)

    $grpOpts             = New-Object System.Windows.Forms.GroupBox
    $grpOpts.Text        = "Elements a capturer"
    $grpOpts.Location    = New-Object System.Drawing.Point(10, 80)
    $grpOpts.Size        = New-Object System.Drawing.Size(680, 68)
    $grpOpts.Font        = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)

    $mkChk = {
        param($t,$x,$y,$w,$checked=$false)
        $c = New-Object System.Windows.Forms.CheckBox
        $c.Text = $t; $c.Location = New-Object System.Drawing.Point($x,$y)
        $c.Size = New-Object System.Drawing.Size($w,20)
        $c.Font = New-Object System.Drawing.Font("Segoe UI",9,[System.Drawing.FontStyle]::Regular)
        $c.Checked = $checked; $c
    }

    $chkCapStruct   = & $mkChk "Arborescence (bibliotheques et dossiers)" 10 22 300 $true
    $chkCapPerms    = & $mkChk "Permissions (groupes et roles)"           320 22 260
    $chkCapSettings = & $mkChk "Parametres du site (titre, langue...)"    10 44 300 $true
    $chkCapStyle    = & $mkChk "Style (logo)"                             320 44 200
    $grpOpts.Controls.AddRange(@($chkCapStruct, $chkCapPerms, $chkCapSettings, $chkCapStyle))

    $btnCapture          = New-Object System.Windows.Forms.Button
    $btnCapture.Text     = "Lancer la capture"
    $btnCapture.Location = New-Object System.Drawing.Point(10, 162)
    $btnCapture.Size     = New-Object System.Drawing.Size(155, 34)
    $btnCapture.BackColor = [System.Drawing.Color]::FromArgb(16,124,16)
    $btnCapture.ForeColor = [System.Drawing.Color]::White
    $btnCapture.FlatStyle = "Flat"
    $btnCapture.Font     = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)

    $txtCapLog           = New-Object System.Windows.Forms.RichTextBox
    $txtCapLog.Location  = New-Object System.Drawing.Point(10, 206)
    $txtCapLog.Size      = New-Object System.Drawing.Size(918, 208)
    $txtCapLog.Anchor    = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
    $txtCapLog.ReadOnly  = $true
    $txtCapLog.BackColor = [System.Drawing.Color]::Black
    $txtCapLog.ForeColor = [System.Drawing.Color]::LightGreen
    $txtCapLog.Font      = New-Object System.Drawing.Font("Consolas", 8)
    $txtCapLog.ScrollBars = "Vertical"

    $tabCap.Controls.AddRange(@($lblCapSrc, $txtCapSrc, $lblCapName, $txtCapName,
        $grpOpts, $btnCapture, $txtCapLog))

    # ── Tab: Creer depuis template ─────────────────────────────────────────
    $tabCr               = New-Object System.Windows.Forms.TabPage
    $tabCr.Text          = "  Creer depuis un template  "
    $tabCr.BackColor     = [System.Drawing.Color]::WhiteSmoke

    $lblCrTpl            = & $mkLbl "Template :" 10 18
    $cboCrTpl            = New-Object System.Windows.Forms.ComboBox
    $cboCrTpl.Location   = New-Object System.Drawing.Point(128, 16)
    $cboCrTpl.Size       = New-Object System.Drawing.Size(400, 24)
    $cboCrTpl.DropDownStyle = "DropDownList"
    $cboCrTpl.Font       = New-Object System.Drawing.Font("Segoe UI", 9)

    $lblCrTitle          = & $mkLbl "Titre du site :" 10 48
    $txtCrTitle          = New-Object System.Windows.Forms.TextBox
    $txtCrTitle.Location = New-Object System.Drawing.Point(128, 46)
    $txtCrTitle.Size     = New-Object System.Drawing.Size(300, 22)
    $txtCrTitle.Font     = New-Object System.Drawing.Font("Segoe UI", 9)

    $lblCrAlias          = & $mkLbl "Alias URL :" 10 78
    $txtCrAlias          = New-Object System.Windows.Forms.TextBox
    $txtCrAlias.Location = New-Object System.Drawing.Point(128, 76)
    $txtCrAlias.Size     = New-Object System.Drawing.Size(200, 22)
    $txtCrAlias.Font     = New-Object System.Drawing.Font("Consolas", 9)

    $lblCrAliasHint      = New-Object System.Windows.Forms.Label
    $lblCrAliasHint.Text = "(lettres, chiffres, tirets uniquement)"
    $lblCrAliasHint.Location = New-Object System.Drawing.Point(336, 78)
    $lblCrAliasHint.Size = New-Object System.Drawing.Size(250, 20)
    $lblCrAliasHint.ForeColor = [System.Drawing.Color]::Gray
    $lblCrAliasHint.Font = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)

    $lblCrType           = & $mkLbl "Type :" 10 108
    $radCrTeam           = New-Object System.Windows.Forms.RadioButton
    $radCrTeam.Text      = "Team Site (avec groupe M365)"
    $radCrTeam.Location  = New-Object System.Drawing.Point(128, 108)
    $radCrTeam.Size      = New-Object System.Drawing.Size(220, 22)
    $radCrTeam.Checked   = $true

    $radCrComm           = New-Object System.Windows.Forms.RadioButton
    $radCrComm.Text      = "Communication Site"
    $radCrComm.Location  = New-Object System.Drawing.Point(358, 108)
    $radCrComm.Size      = New-Object System.Drawing.Size(200, 22)

    $lblCrOwners         = & $mkLbl "Proprietaires :" 10 138
    $txtCrOwners         = New-Object System.Windows.Forms.TextBox
    $txtCrOwners.Location = New-Object System.Drawing.Point(128, 136)
    $txtCrOwners.Size    = New-Object System.Drawing.Size(550, 22)
    $txtCrOwners.Font    = New-Object System.Drawing.Font("Segoe UI", 9)
    $txtCrOwners.PlaceholderText = "user1@domain.com, user2@domain.com"

    $lblCrMembers        = & $mkLbl "Membres :" 10 168
    $txtCrMembers        = New-Object System.Windows.Forms.TextBox
    $txtCrMembers.Location = New-Object System.Drawing.Point(128, 166)
    $txtCrMembers.Size   = New-Object System.Drawing.Size(410, 22)
    $txtCrMembers.Font   = New-Object System.Drawing.Font("Segoe UI", 9)
    $txtCrMembers.PlaceholderText = "user@domain.com, ..."

    $btnCrCsv            = New-Object System.Windows.Forms.Button
    $btnCrCsv.Text       = "Importer CSV..."
    $btnCrCsv.Location   = New-Object System.Drawing.Point(546, 164)
    $btnCrCsv.Size       = New-Object System.Drawing.Size(120, 26)

    $grpApply            = New-Object System.Windows.Forms.GroupBox
    $grpApply.Text       = "Appliquer depuis le template"
    $grpApply.Location   = New-Object System.Drawing.Point(10, 200)
    $grpApply.Size       = New-Object System.Drawing.Size(680, 60)
    $grpApply.Font       = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)

    $chkApplyStruct      = & $mkChk "Arborescence"  10 22 140 $true
    $chkApplyPerms       = & $mkChk "Permissions"   158 22 120
    $chkApplySettings    = & $mkChk "Parametres"    286 22 120 $true
    $chkApplyStyle       = & $mkChk "Style"         414 22 100
    $grpApply.Controls.AddRange(@($chkApplyStruct, $chkApplyPerms, $chkApplySettings, $chkApplyStyle))

    $btnCreateSite       = New-Object System.Windows.Forms.Button
    $btnCreateSite.Text  = "Creer le site"
    $btnCreateSite.Location = New-Object System.Drawing.Point(10, 272)
    $btnCreateSite.Size  = New-Object System.Drawing.Size(155, 34)
    $btnCreateSite.BackColor = [System.Drawing.Color]::SteelBlue
    $btnCreateSite.ForeColor = [System.Drawing.Color]::White
    $btnCreateSite.FlatStyle = "Flat"
    $btnCreateSite.Font  = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)

    $txtCreateLog        = New-Object System.Windows.Forms.RichTextBox
    $txtCreateLog.Location = New-Object System.Drawing.Point(10, 316)
    $txtCreateLog.Size   = New-Object System.Drawing.Size(918, 100)
    $txtCreateLog.Anchor = [System.Windows.Forms.AnchorStyles]"Top,Bottom,Left,Right"
    $txtCreateLog.ReadOnly = $true
    $txtCreateLog.BackColor = [System.Drawing.Color]::Black
    $txtCreateLog.ForeColor = [System.Drawing.Color]::LightGreen
    $txtCreateLog.Font   = New-Object System.Drawing.Font("Consolas", 8)
    $txtCreateLog.ScrollBars = "Vertical"

    $tabCr.Controls.AddRange(@(
        $lblCrTpl, $cboCrTpl,
        $lblCrTitle, $txtCrTitle,
        $lblCrAlias, $txtCrAlias, $lblCrAliasHint,
        $lblCrType, $radCrTeam, $radCrComm,
        $lblCrOwners, $txtCrOwners,
        $lblCrMembers, $txtCrMembers, $btnCrCsv,
        $grpApply,
        $btnCreateSite, $txtCreateLog
    ))

    $btabs.TabPages.AddRange(@($tabCap, $tabCr))
    $dlg.Controls.AddRange(@($grpList, $btabs))

    # ── Init state ─────────────────────────────────────────────────────────
    $script:_tpl = @{
        Lv           = $lv
        BtnDelete    = $btnDelete
        BtnCreate    = $btnCreate
        CboCrTpl     = $cboCrTpl
        TxtCapLog    = $txtCapLog
        TxtCreateLog = $txtCreateLog
        Templates    = @(Load-Templates)
        ClientId     = $ClientId
        TenantUrl    = $TenantUrl
        CapSync      = $null; CapTimer = $null; CapRS = $null; CapPS = $null; CapHnd = $null
        CrSync       = $null; CrTimer  = $null; CrRS  = $null; CrPS  = $null; CrHnd  = $null
        CapTplName   = ""; CapSrcUrl = ""; CapOpts = $null
    }
    _Tpl-Repopulate
    _Tpl-RefreshCombo

    # ── Event handlers ─────────────────────────────────────────────────────
    $lv.Add_SelectedIndexChanged({
        $sel = $script:_tpl.Lv.SelectedItems.Count -gt 0
        $script:_tpl.BtnDelete.Enabled = $sel
        $script:_tpl.BtnCreate.Enabled = $sel
    })

    $btnDelete.Add_Click({
        $item = $script:_tpl.Lv.SelectedItems[0]
        if (-not $item) { return }
        $name = $item.Text
        $res  = [System.Windows.Forms.MessageBox]::Show(
            "Supprimer le template '$name' ?", "Confirmer", "YesNo", "Warning")
        if ($res -ne "Yes") { return }
        $script:_tpl.Templates = @($script:_tpl.Templates | Where-Object { $_.name -ne $name })
        Save-Templates -Templates $script:_tpl.Templates
        _Tpl-Repopulate
        _Tpl-RefreshCombo
    })

    $btnCreate.Add_Click({
        $item = $script:_tpl.Lv.SelectedItems[0]
        if (-not $item) { return }
        $t   = $item.Tag
        $idx = [array]::IndexOf(($script:_tpl.Templates | ForEach-Object { $_.name }), $t.name)
        if ($idx -ge 0) { $script:_tpl.CboCrTpl.SelectedIndex = $idx }
        $btabs.SelectedTab = $tabCr
    })

    # Auto-generate alias from title
    $txtCrTitle.Add_TextChanged({
        $a = $txtCrTitle.Text -replace '[^a-zA-Z0-9 \-]','' -replace '\s+','-' -replace '\-+','-'
        $a = $a.ToLower().Trim('-')
        if ($a.Length -gt 64) { $a = $a.Substring(0,64) }
        $txtCrAlias.Text = $a
    })

    # CSV import for members
    $btnCrCsv.Add_Click({
        $ofd        = New-Object System.Windows.Forms.OpenFileDialog
        $ofd.Filter = "CSV (*.csv)|*.csv|Tous (*.*)|*.*"
        $ofd.Title  = "Importer des membres depuis CSV"
        if ($ofd.ShowDialog() -ne "OK") { return }
        try {
            $rows   = Import-Csv $ofd.FileName
            $emails = $rows | ForEach-Object {
                $r = $_
                $v = if ($r.Email) { $r.Email } elseif ($r.email) { $r.email } `
                     elseif ($r.UPN) { $r.UPN } elseif ($r.upn) { $r.upn } `
                     elseif ($r.UserPrincipalName) { $r.UserPrincipalName } `
                     else { $r.userprincipalname }
                $v
            } | Where-Object { $_ } | Select-Object -Unique
            $txtCrMembers.Text = ($emails -join ", ")
            [System.Windows.Forms.MessageBox]::Show(
                "$($emails.Count) membre(s) importe(s).", "Import CSV", "OK", "Information")
        } catch {
            [System.Windows.Forms.MessageBox]::Show(
                "Erreur CSV: $($_.Exception.Message)", "Erreur", "OK", "Error")
        }
    })

    # ── Capture button ─────────────────────────────────────────────────────
    $btnCapture.Add_Click({
        $srcUrl  = $txtCapSrc.Text.Trim()
        $tplName = $txtCapName.Text.Trim()
        if ([string]::IsNullOrWhiteSpace($srcUrl)) {
            [System.Windows.Forms.MessageBox]::Show("Veuillez saisir l'URL du site source.", "Champ manquant", "OK", "Warning")
            return
        }
        if ([string]::IsNullOrWhiteSpace($tplName)) {
            [System.Windows.Forms.MessageBox]::Show("Veuillez saisir un nom pour le template.", "Champ manquant", "OK", "Warning")
            return
        }
        if ([string]::IsNullOrWhiteSpace($script:_tpl.ClientId)) {
            [System.Windows.Forms.MessageBox]::Show("Client ID manquant dans le formulaire principal.", "Erreur", "OK", "Warning")
            return
        }

        # Stash locals so timer tick can read them from script scope
        $script:_tpl.CapTplName = $tplName
        $script:_tpl.CapSrcUrl  = $srcUrl
        $script:_tpl.CapOpts    = @{
            structure   = $chkCapStruct.Checked
            permissions = $chkCapPerms.Checked
            settings    = $chkCapSettings.Checked
            style       = $chkCapStyle.Checked
        }

        $btnCapture.Enabled = $false
        $script:_tpl.TxtCapLog.Clear()

        $bgCapture = {
            param($SiteUrl, $ClientId, $Opts, $Sync)
            function BgLog([string]$m, [string]$c="LightGreen") {
                $Sync.Queue.Enqueue([PSCustomObject]@{ Text=$m; Color=$c })
            }
            function Collect-Folders([string]$SiteRelUrl) {
                $out = [System.Collections.Generic.List[object]]::new()
                try {
                    $items = Get-PnPFolderItem -FolderSiteRelativeUrl $SiteRelUrl -ItemType Folder -ErrorAction SilentlyContinue
                    foreach ($fi in $items) {
                        if ($fi.Name -match '^_|^Forms$') { continue }
                        $sub = Collect-Folders "$SiteRelUrl/$($fi.Name)"
                        $out.Add(@{ name=$fi.Name; subfolders=@($sub) })
                    }
                } catch {}
                return @($out)
            }
            try {
                Import-Module PnP.PowerShell -ErrorAction Stop
                BgLog "Connexion a $SiteUrl..." "Yellow"
                Connect-PnPOnline -Url $SiteUrl -Interactive -ClientId $ClientId
                $web  = Get-PnPWeb -Includes Title,Description,Language,SiteLogoUrl
                $wSrl = $web.ServerRelativeUrl.TrimEnd('/')
                $result = @{ settings=@{}; style=@{}; structure=@(); permissions=@(); folderPermissions=@() }

                if ($Opts.settings) {
                    BgLog "Capture des parametres..." "Yellow"
                    $result.settings = @{
                        title       = $web.Title
                        description = $web.Description
                        language    = [int]$web.Language
                    }
                    BgLog "  Titre: $($web.Title) | Langue: $($web.Language)" "Cyan"
                }

                if ($Opts.style) {
                    BgLog "Capture du style..." "Yellow"
                    $result.style = @{ logoUrl = $web.SiteLogoUrl }
                    BgLog "  Logo: $($web.SiteLogoUrl)" "Cyan"
                }

                if ($Opts.structure) {
                    BgLog "Capture de l'arborescence..." "Yellow"
                    $lists  = Get-PnPList | Where-Object {
                        !$_.Hidden -and ($_.BaseType -eq "DocumentLibrary" -or $_.BaseType -eq "GenericList")
                    }
                    $struct = [System.Collections.Generic.List[object]]::new()
                    foreach ($list in $lists) {
                        $rf  = Get-PnPProperty -ClientObject $list -Property RootFolder
                        $srl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
                        $fld = Collect-Folders $srl
                        $struct.Add(@{
                            name        = $list.Title
                            type        = $list.BaseType.ToString()
                            template    = [int]$list.BaseTemplate
                            rootSiteRel = $srl          # site-relative URL of library root
                            folders     = @($fld)
                        })
                        BgLog "  [$($list.BaseType)] $($list.Title) ($srl) - $($fld.Count) dossier(s)" "Cyan"
                    }
                    $result.structure = @($struct)
                }

                if ($Opts.permissions) {
                    BgLog "Capture des groupes site..." "Yellow"
                    $groups  = Get-PnPSiteGroup
                    $permArr = [System.Collections.Generic.List[object]]::new()
                    foreach ($g in $groups) {
                        try {
                            $members = @(Get-PnPGroupMember -Identity $g.LoginName -ErrorAction SilentlyContinue |
                                Where-Object { $_.Title -ne "System Account" } |
                                Select-Object -ExpandProperty LoginName)
                            $roles = @(Get-PnPGroupPermissions -Identity $g.LoginName -ErrorAction SilentlyContinue |
                                Select-Object -ExpandProperty Name)
                            $permArr.Add(@{
                                groupName = $g.Title
                                loginName = $g.LoginName
                                roles     = @($roles)
                                members   = @($members)
                            })
                            BgLog "  Groupe: $($g.Title) - $($members.Count) membre(s)" "Cyan"
                        } catch {}
                    }
                    $result.permissions = @($permArr)

                    # Capture des permissions uniques sur les dossiers
                    BgLog "Scan des permissions sur les dossiers..." "Yellow"
                    $scanLists = Get-PnPList | Where-Object {
                        !$_.Hidden -and ($_.BaseType -eq "DocumentLibrary" -or $_.BaseType -eq "GenericList")
                    }
                    $folderPerms = [System.Collections.Generic.List[object]]::new()
                    $ctx = Get-PnPContext
                    foreach ($scanList in $scanLists) {
                        $rf      = Get-PnPProperty -ClientObject $scanList -Property RootFolder
                        $listSrl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
                        try {
                            $items = Get-PnPListItem -List $scanList -PageSize 2000 -ErrorAction SilentlyContinue |
                                Where-Object { $_.FileSystemObjectType -eq "Folder" }
                            foreach ($folder in $items) {
                                try {
                                    $hasUnique = Get-PnPProperty -ClientObject $folder -Property HasUniqueRoleAssignments
                                    if (-not $hasUnique) { continue }
                                    $ra = Get-PnPProperty -ClientObject $folder -Property RoleAssignments
                                    $folderRoleArr = [System.Collections.Generic.List[object]]::new()
                                    foreach ($assignment in $ra) {
                                        Get-PnPProperty -ClientObject $assignment.Member -Property Title,LoginName | Out-Null
                                        Get-PnPProperty -ClientObject $assignment -Property RoleDefinitionBindings | Out-Null
                                        $rNames = @($assignment.RoleDefinitionBindings |
                                            Where-Object { $_.Name -ne "Limited Access" } |
                                            Select-Object -ExpandProperty Name)
                                        if ($rNames.Count -gt 0) {
                                            $folderRoleArr.Add(@{
                                                principal = $assignment.Member.Title
                                                loginName = $assignment.Member.LoginName
                                                roles     = @($rNames)
                                            })
                                        }
                                    }
                                    $fileRef  = $folder.FieldValues.FileRef
                                    $relPath  = $fileRef.Substring($wSrl.Length).TrimStart('/')
                                    $folderPerms.Add(@{
                                        listSiteRel = $listSrl
                                        path        = $relPath
                                        perms       = @($folderRoleArr)
                                    })
                                    BgLog "    Perms uniques: $relPath ($($folderRoleArr.Count) entree(s))" "Cyan"
                                } catch {}
                            }
                        } catch { BgLog "  Liste ignoree '$($scanList.Title)': $($_.Exception.Message)" "DarkGray" }
                    }
                    $result.folderPermissions = @($folderPerms)
                    BgLog "  $($folderPerms.Count) dossier(s) avec permissions uniques captures" "LightGreen"
                }

                $Sync.Result = $result
                BgLog "=== Capture terminee ! ===" "White"
            } catch {
                $Sync.Error = $_.Exception.Message
                BgLog "Erreur: $($_.Exception.Message)" "Red"
            } finally { $Sync.Done = $true }
        }

        $sync = [hashtable]::Synchronized(@{
            Queue  = [System.Collections.Generic.Queue[object]]::new()
            Done   = $false; Error = $null; Result = $null
        })
        $script:_tpl.CapSync = $sync

        $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
        $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
        $ps = [System.Management.Automation.PowerShell]::Create()
        $ps.Runspace = $rs
        [void]$ps.AddScript($bgCapture)
        [void]$ps.AddArgument($srcUrl)
        [void]$ps.AddArgument($script:_tpl.ClientId)
        [void]$ps.AddArgument($script:_tpl.CapOpts)
        [void]$ps.AddArgument($sync)
        $script:_tpl.CapRS  = $rs
        $script:_tpl.CapPS  = $ps
        $script:_tpl.CapHnd = $ps.BeginInvoke()

        $tmr          = New-Object System.Windows.Forms.Timer
        $tmr.Interval = 200
        $script:_tpl.CapTimer = $tmr
        $tmr.Add_Tick({
            while ($script:_tpl.CapSync.Queue.Count -gt 0) {
                $m = $script:_tpl.CapSync.Queue.Dequeue()
                _Tpl-Log -Box $script:_tpl.TxtCapLog -Msg $m.Text -Color $m.Color
            }
            if ($script:_tpl.CapSync.Done) {
                $script:_tpl.CapTimer.Stop(); $script:_tpl.CapTimer.Dispose()
                while ($script:_tpl.CapSync.Queue.Count -gt 0) {
                    $m = $script:_tpl.CapSync.Queue.Dequeue()
                    _Tpl-Log -Box $script:_tpl.TxtCapLog -Msg $m.Text -Color $m.Color
                }
                try { [void]$script:_tpl.CapPS.EndInvoke($script:_tpl.CapHnd) } catch {}
                try { $script:_tpl.CapRS.Close(); $script:_tpl.CapRS.Dispose() } catch {}
                $btnCapture.Enabled = $true
                if (-not $script:_tpl.CapSync.Error -and $script:_tpl.CapSync.Result) {
                    $r   = $script:_tpl.CapSync.Result
                    $newTpl = [PSCustomObject]@{
                        name              = $script:_tpl.CapTplName
                        sourceUrl         = $script:_tpl.CapSrcUrl
                        capturedAt        = (Get-Date -Format 'dd/MM/yyyy HH:mm')
                        options           = $script:_tpl.CapOpts
                        settings          = $r.settings
                        style             = $r.style
                        structure         = $r.structure
                        permissions       = $r.permissions
                        folderPermissions = $r.folderPermissions
                    }
                    $n = $script:_tpl.CapTplName
                    $script:_tpl.Templates = @($script:_tpl.Templates | Where-Object { $_.name -ne $n }) + $newTpl
                    Save-Templates -Templates $script:_tpl.Templates
                    _Tpl-Repopulate
                    _Tpl-RefreshCombo
                    [System.Windows.Forms.MessageBox]::Show(
                        "Template '$n' sauvegarde avec succes.",
                        "Capture reussie", "OK", "Information")
                }
            }
        })
        $tmr.Start()
    })

    # ── Create site button ─────────────────────────────────────────────────
    $btnCreateSite.Add_Click({
        $tplIdx = $cboCrTpl.SelectedIndex
        if ($tplIdx -lt 0 -or $tplIdx -ge $script:_tpl.Templates.Count) {
            [System.Windows.Forms.MessageBox]::Show("Veuillez selectionner un template.", "Aucun template", "OK", "Warning")
            return
        }
        $tpl     = $script:_tpl.Templates[$tplIdx]
        $title   = $txtCrTitle.Text.Trim()
        $alias   = $txtCrAlias.Text.Trim()
        $owners  = @($txtCrOwners.Text  -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
        $members = @($txtCrMembers.Text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })

        if ([string]::IsNullOrWhiteSpace($title)) {
            [System.Windows.Forms.MessageBox]::Show("Veuillez saisir le titre du site.", "Champ manquant", "OK", "Warning"); return
        }
        if ([string]::IsNullOrWhiteSpace($alias)) {
            [System.Windows.Forms.MessageBox]::Show("Veuillez saisir un alias URL.", "Champ manquant", "OK", "Warning"); return
        }
        if ([string]::IsNullOrWhiteSpace($script:_tpl.TenantUrl)) {
            [System.Windows.Forms.MessageBox]::Show("Tenant URL manquant dans le formulaire principal.", "Erreur", "OK", "Warning"); return
        }
        if ($owners.Count -eq 0) {
            [System.Windows.Forms.MessageBox]::Show("Veuillez saisir au moins un proprietaire.", "Champ manquant", "OK", "Warning"); return
        }

        $applyOpts = @{
            structure   = $chkApplyStruct.Checked   -and $tpl.options.structure
            permissions = $chkApplyPerms.Checked    -and $tpl.options.permissions
            settings    = $chkApplySettings.Checked -and $tpl.options.settings
            style       = $chkApplyStyle.Checked    -and $tpl.options.style
        }
        $isTeam = $radCrTeam.Checked

        $btnCreateSite.Enabled = $false
        $script:_tpl.TxtCreateLog.Clear()

        $bgCreate = {
            param($TenantUrl, $ClientId, $Title, $Alias, $IsTeam, $Owners, $Members, $ApplyOpts, $Tpl, $Sync)
            function BgLog([string]$m, [string]$c="LightGreen") {
                $Sync.Queue.Enqueue([PSCustomObject]@{ Text=$m; Color=$c })
            }
            function Apply-FolderTree([object[]]$Folders, [string]$ParentSrl) {
                foreach ($f in $Folders) {
                    try {
                        Add-PnPFolder -Name $f.name -Folder $ParentSrl -ErrorAction SilentlyContinue | Out-Null
                        BgLog "      + $($f.name)" "Cyan"
                    } catch {}
                    if ($f.subfolders -and $f.subfolders.Count -gt 0) {
                        Apply-FolderTree $f.subfolders "$ParentSrl/$($f.name)"
                    }
                }
            }
            try {
                Import-Module PnP.PowerShell -ErrorAction Stop
                $adminUrl = if ($TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') {
                    "$($Matches[1])-admin$($Matches[2])"
                } else { $TenantUrl }

                BgLog "Connexion au tenant admin..." "Yellow"
                Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $ClientId

                BgLog "Creation du site '$Title' (alias: $Alias)..." "Yellow"
                $newUrl = if ($IsTeam) {
                    New-PnPSite -Type TeamSite -Title $Title -Alias $Alias -Owners $Owners -Wait
                } else {
                    $base = if ($TenantUrl -match '^(https?://[^.]+\.sharepoint\.com)') { $Matches[1] } else { $TenantUrl }
                    New-PnPSite -Type CommunicationSite -Title $Title -Url "$base/sites/$Alias" -Wait
                }
                $Sync.NewSiteUrl = $newUrl
                BgLog "Site cree : $newUrl" "LightGreen"

                BgLog "Connexion au nouveau site..." "Yellow"
                Connect-PnPOnline -Url $newUrl -Interactive -ClientId $ClientId
                $web  = Get-PnPWeb
                $wSrl = $web.ServerRelativeUrl.TrimEnd('/')

                if ($ApplyOpts.settings -and $Tpl.settings -and $Tpl.settings.description) {
                    BgLog "Application des parametres..." "Yellow"
                    Set-PnPWeb -Description $Tpl.settings.description
                }

                if ($ApplyOpts.style -and $Tpl.style -and $Tpl.style.logoUrl) {
                    BgLog "Application du style (logo)..." "Yellow"
                    Set-PnPWeb -SiteLogoUrl $Tpl.style.logoUrl
                }

                if ($ApplyOpts.structure -and $Tpl.structure -and $Tpl.structure.Count -gt 0) {
                    BgLog "Application de l'arborescence..." "Yellow"
                    foreach ($lib in $Tpl.structure) {
                        BgLog "  Bibliotheque: $($lib.name)" "Yellow"
                        $existing = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
                        if (-not $existing) {
                            try {
                                $tplType = if ($lib.template -eq 101 -or $lib.type -eq "DocumentLibrary") {
                                    [Microsoft.SharePoint.Client.ListTemplateType]::DocumentLibrary
                                } else {
                                    [Microsoft.SharePoint.Client.ListTemplateType]::GenericList
                                }
                                New-PnPList -Title $lib.name -Template $tplType | Out-Null
                                BgLog "    Creee." "Cyan"
                            } catch { BgLog "    Ignoree: $($_.Exception.Message)" "DarkGray" }
                        } else { BgLog "    Deja existante." "DarkGray" }

                        if ($lib.folders -and $lib.folders.Count -gt 0) {
                            # Get actual root folder URL from target list (avoids display-name vs URL mismatch)
                            $targetList = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
                            if ($targetList) {
                                $listRf  = Get-PnPProperty -ClientObject $targetList -Property RootFolder
                                $libBase = $listRf.ServerRelativeUrl.TrimEnd('/')
                                BgLog "    Base URL: $libBase" "DarkGray"
                                Apply-FolderTree $lib.folders $libBase
                            } else {
                                BgLog "    Liste '$($lib.name)' non trouvee, dossiers ignores." "DarkGray"
                            }
                        }
                    }
                }

                if ($Members -and $Members.Count -gt 0) {
                    BgLog "Ajout de $($Members.Count) membre(s)..." "Yellow"
                    $memberGroup = Get-PnPGroup | Where-Object { $_.Title -like "*Membres*" -or $_.Title -like "*Members*" } | Select-Object -First 1
                    if ($memberGroup) {
                        foreach ($m in $Members) {
                            try {
                                Add-PnPGroupMember -LoginName $m -Group $memberGroup.Title -ErrorAction SilentlyContinue
                                BgLog "  + $m" "Cyan"
                            } catch { BgLog "  Ignore $m" "DarkGray" }
                        }
                    } else { BgLog "  Groupe membres non trouve, ajout ignore." "DarkGray" }
                }

                # Apply folder-level unique permissions
                $fpList = $Tpl.folderPermissions
                if ($ApplyOpts.permissions -and $fpList -and $fpList.Count -gt 0) {
                    BgLog "Application des permissions sur les dossiers ($($fpList.Count))..." "Yellow"

                    # Build map: source library rootSiteRel -> target library server-relative URL
                    $libMap = @{}
                    foreach ($lib in $Tpl.structure) {
                        if (-not $lib.rootSiteRel) { continue }
                        $tgtLib = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
                        if ($tgtLib) {
                            $tgtRf = Get-PnPProperty -ClientObject $tgtLib -Property RootFolder
                            $libMap[$lib.rootSiteRel] = $tgtRf.ServerRelativeUrl.TrimEnd('/')
                        }
                    }

                    $ctx = Get-PnPContext
                    foreach ($fp in $fpList) {
                        # Compute folder SRL on target using the library map
                        $srcLibSrl  = $fp.listSiteRel
                        $tgtLibBase = $libMap[$srcLibSrl]
                        if (-not $tgtLibBase) {
                            BgLog "  Bibliotheque non mappee pour '$srcLibSrl', dossier ignore." "DarkGray"
                            continue
                        }
                        # Folder path relative to library root (strip the lib prefix from fp.path)
                        $folderRelToLib = $fp.path.Substring($srcLibSrl.Length).TrimStart('/')
                        $folderSrl      = "$tgtLibBase/$folderRelToLib"

                        try {
                            $folder     = $ctx.Web.GetFolderByServerRelativeUrl($folderSrl)
                            $folderItem = $folder.ListItemAllFields
                            $ctx.Load($folderItem)
                            $ctx.ExecuteQuery()

                            # Break inheritance and clear all inherited permissions
                            $folderItem.BreakRoleInheritance($false, $false)
                            $ctx.ExecuteQuery()

                            # Apply each captured permission entry
                            foreach ($perm in $fp.perms) {
                                # Skip system accounts and source-specific SP groups (detected by failed EnsureUser)
                                try {
                                    $principal = $ctx.Web.EnsureUser($perm.loginName)
                                    $ctx.Load($principal)
                                    $ctx.ExecuteQuery()
                                    foreach ($roleName in $perm.roles) {
                                        try {
                                            $roleDef  = $ctx.Web.RoleDefinitions.GetByName($roleName)
                                            $bindings = New-Object Microsoft.SharePoint.Client.RoleDefinitionBindingCollection($ctx)
                                            $bindings.Add($roleDef)
                                            $folderItem.RoleAssignments.Add($principal, $bindings) | Out-Null
                                            $ctx.ExecuteQuery()
                                            BgLog "    + $($perm.principal) [$roleName]" "Cyan"
                                        } catch { BgLog "    Role '$roleName' ignore pour '$($perm.principal)'" "DarkGray" }
                                    }
                                } catch {
                                    BgLog "    Principal ignore (groupe source ou compte systeme) : '$($perm.principal)'" "DarkGray"
                                }
                            }
                            BgLog "  OK: $folderRelToLib" "Cyan"
                        } catch { BgLog "  Dossier ignore '$folderRelToLib': $($_.Exception.Message)" "DarkGray" }
                    }
                }

                BgLog "=== Site cree avec succes ! ===" "White"
            } catch {
                $Sync.Error = $_.Exception.Message
                BgLog "Erreur: $($_.Exception.Message)" "Red"
            } finally { $Sync.Done = $true }
        }

        $sync = [hashtable]::Synchronized(@{
            Queue      = [System.Collections.Generic.Queue[object]]::new()
            Done       = $false; Error = $null; NewSiteUrl = $null
        })
        $script:_tpl.CrSync = $sync

        $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
        $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
        $ps = [System.Management.Automation.PowerShell]::Create()
        $ps.Runspace = $rs
        [void]$ps.AddScript($bgCreate)
        [void]$ps.AddArgument($script:_tpl.TenantUrl)
        [void]$ps.AddArgument($script:_tpl.ClientId)
        [void]$ps.AddArgument($title)
        [void]$ps.AddArgument($alias)
        [void]$ps.AddArgument($isTeam)
        [void]$ps.AddArgument($owners)
        [void]$ps.AddArgument($members)
        [void]$ps.AddArgument($applyOpts)
        [void]$ps.AddArgument($tpl)
        [void]$ps.AddArgument($sync)
        $script:_tpl.CrRS  = $rs
        $script:_tpl.CrPS  = $ps
        $script:_tpl.CrHnd = $ps.BeginInvoke()

        $tmr          = New-Object System.Windows.Forms.Timer
        $tmr.Interval = 200
        $script:_tpl.CrTimer = $tmr
        $tmr.Add_Tick({
            while ($script:_tpl.CrSync.Queue.Count -gt 0) {
                $m = $script:_tpl.CrSync.Queue.Dequeue()
                _Tpl-Log -Box $script:_tpl.TxtCreateLog -Msg $m.Text -Color $m.Color
            }
            if ($script:_tpl.CrSync.Done) {
                $script:_tpl.CrTimer.Stop(); $script:_tpl.CrTimer.Dispose()
                while ($script:_tpl.CrSync.Queue.Count -gt 0) {
                    $m = $script:_tpl.CrSync.Queue.Dequeue()
                    _Tpl-Log -Box $script:_tpl.TxtCreateLog -Msg $m.Text -Color $m.Color
                }
                try { [void]$script:_tpl.CrPS.EndInvoke($script:_tpl.CrHnd) } catch {}
                try { $script:_tpl.CrRS.Close(); $script:_tpl.CrRS.Dispose() } catch {}
                $btnCreateSite.Enabled = $true
                if ($script:_tpl.CrSync.NewSiteUrl -and -not $script:_tpl.CrSync.Error) {
                    $url = $script:_tpl.CrSync.NewSiteUrl
                    $res = [System.Windows.Forms.MessageBox]::Show(
                        "Site cree avec succes !`n`n$url`n`nOuvrir dans le navigateur ?",
                        "Succes", "YesNo", "Information")
                    if ($res -eq "Yes") { Start-Process $url }
                }
            }
        })
        $tmr.Start()
    })

    if ($Owner) { [void]$dlg.ShowDialog($Owner) } else { [void]$dlg.ShowDialog() }
    $dlg.Dispose()
}

#endregion

#region ===== HTML Export: Permissions =====

function Merge-PermissionRows([array]$Data) {
    # Groups rows that share the same Users + Permissions + GrantedThrough into one merged row.
    # Uses [ordered] to preserve insertion order without a separate list.
    $map = [ordered]@{}
    foreach ($row in $Data) {
        $key = "$($row.Users)|$($row.Permissions)|$($row.GrantedThrough)"
        if (-not $map.Contains($key)) {
            $map[$key] = [PSCustomObject]@{
                Locations      = @()
                Permissions    = $row.Permissions
                GrantedThrough = $row.GrantedThrough
                Type           = $row.Type
                Users          = $row.Users
                UserLogins     = if ($row.UserLogins) { $row.UserLogins } else { "" }
            }
        }
        $map[$key].Locations += [PSCustomObject]@{
            Object               = [string]$row.Object
            Title                = [string]$row.Title
            URL                  = if ($row.URL) { [string]$row.URL } else { "" }
            HasUniquePermissions = $row.HasUniquePermissions
        }
    }
    return @($map.Values)
}

function Export-PermissionsToHTML {
    param([array]$Data, [string]$SiteTitle, [string]$SiteURL, [string]$OutputPath)

    $generated   = Get-Date -Format 'dd/MM/yyyy HH:mm'
    $count       = $Data.Count
    $uniqueCount = ($Data | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true } | Measure-Object).Count
    $userCount   = ($Data | ForEach-Object { $_.Users -split '; ' } | Where-Object { $_ } | Sort-Object -Unique | Measure-Object).Count

    # Build pills HTML for a list of names + emails
    function Build-Pills([string[]]$Names, [string[]]$Emails) {
        $html = ""
        for ($i = 0; $i -lt $Names.Count; $i++) {
            $n = EscHtml $Names[$i]
            $e = if ($Emails -and $i -lt $Emails.Count) { EscHtml $Emails[$i] } else { "" }
            $html += "<span class='pill' data-email='$e'>$n</span>"
        }
        return $html
    }

    $mergedRows = Merge-PermissionRows $Data
    $script:grpIdx = 0
    $rows = ""
    foreach ($mrow in $mergedRows) {
        $locs = @($mrow.Locations)

        # Dominant type badge (use first location's type - entries in a merged group are typically the same type)
        $dominantType = $locs[0].Object
        $badgeClass = switch -Regex ($dominantType) {
            "Site Collection" { "bc"; break }
            "^Site$"          { "bs"; break }
            "Folder"          { "bf"; break }
            Default           { "bl" }
        }

        # Name / locations cell + Unique Permissions cell
        if ($locs.Count -eq 1) {
            $loc      = $locs[0]
            $isUnique = ($loc.HasUniquePermissions -eq 'TRUE' -or $loc.HasUniquePermissions -eq $true)
            $uqClass  = if ($isUnique) { "uq" } else { "inh" }
            $uqText   = if ($isUnique) { "&#10003; Unique" } else { "Inherited" }
            $nameCell = if ($loc.URL) { "<a href='$(EscHtml $loc.URL)'>$(EscHtml $loc.Title)</a>" } else { EscHtml $loc.Title }
            $uqCell   = "<span class='$uqClass'>$uqText</span>"
        } else {
            $uqTotal  = ($locs | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true }).Count
            $locHtml  = "<div class='locs'>"
            foreach ($loc in $locs) {
                $isUnique = ($loc.HasUniquePermissions -eq 'TRUE' -or $loc.HasUniquePermissions -eq $true)
                $uqMark   = if ($isUnique) { "<span class='uq-sm'>&#10003;</span>" } else { "<span class='inh-sm'>~</span>" }
                $locBc    = switch -Regex ($loc.Object) {
                    "Site Collection" { "bc"; break }
                    "^Site$"          { "bs"; break }
                    "Folder"          { "bf"; break }
                    Default           { "bl" }
                }
                $locLink  = if ($loc.URL) { "<a href='$(EscHtml $loc.URL)'>$(EscHtml $loc.Title)</a>" } else { EscHtml $loc.Title }
                $locHtml += "<div class='loc'><span class='b $locBc'>$(EscHtml $loc.Object)</span> $locLink $uqMark</div>"
            }
            $locHtml += "</div>"
            $nameCell = $locHtml
            $uqCell   = "<span class='loc-count'>$($locs.Count) emplacements<br><span class='inh-sm'>($uqTotal uniques)</span></span>"
        }

        # Build Users / Members cell
        $names  = if ($mrow.Users)      { @($mrow.Users      -split '; ') } else { @() }
        $emails = if ($mrow.UserLogins) { @($mrow.UserLogins -split '; ') } else { @() }
        $usersCell = ""
        if ($names.Count -gt 0) {
            $pills = Build-Pills $names $emails
            if ($mrow.GrantedThrough -match '^SharePoint Group: (.+)$') {
                $grpName   = EscHtml $Matches[1]
                $gid       = "g$($script:grpIdx)"
                $script:grpIdx++
                $usersCell = "<div class='grp-wrap'><span class='grp-tog' onclick='toggleGrp(this,""$gid"")'>$grpName</span><div class='grp-members' id='$gid'>$pills</div></div>"
            } else {
                $usersCell = $pills
            }
        }

        $rows += "<tr><td><span class='b $badgeClass'>$(EscHtml $dominantType)</span></td>"
        $rows += "<td>$nameCell</td>"
        $rows += "<td>$usersCell</td>"
        $rows += "<td>$(EscHtml $mrow.Permissions)</td>"
        $rows += "<td>$(EscHtml $mrow.GrantedThrough)</td>"
        $rows += "<td>$uqCell</td></tr>`n"
    }

$html = @"
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Permissions - $(EscHtml $SiteTitle)</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;padding:30px}
h1{font-size:21px;font-weight:600;margin-bottom:6px}
.hdr{background:#0078d4;color:#fff;padding:22px 28px;border-radius:10px;margin-bottom:22px}
.hdr .sub{font-size:13px;opacity:.85;margin-top:4px}
.hdr a{color:#cce4ff}
.srch{background:#fff;border-radius:8px;padding:10px 14px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
.srch input{width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:13px;outline:none}
.srch input:focus{border-color:#0078d4}
.hidden{display:none}
.cards{display:flex;gap:14px;margin-bottom:22px}
.card{background:#fff;border-radius:8px;padding:16px 20px;flex:1;box-shadow:0 1px 4px rgba(0,0,0,.08);text-align:center}
.card .v{font-size:26px;font-weight:700;color:#0078d4}
.card .l{font-size:12px;color:#888;margin-top:3px}
.wrap{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.08);overflow:hidden}
table{border-collapse:collapse;width:100%}
th{background:#0078d4;color:#fff;padding:11px 14px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
td{padding:9px 14px;font-size:13px;border-bottom:1px solid #f0f0f0;vertical-align:top}
tr:last-child td{border-bottom:none}
tr:hover td{background:#f5f9ff}
a{color:#0078d4;text-decoration:none}
a:hover{text-decoration:underline}
.b{display:inline-block;padding:2px 9px;border-radius:20px;font-size:11px;font-weight:600;white-space:nowrap}
.bc{background:#f3e5f5;color:#7b1fa2}
.bs{background:#e3f2fd;color:#1565c0}
.bl{background:#e8f5e9;color:#2e7d32}
.bf{background:#fff3e0;color:#e65100}
.uq{color:#c62828;font-weight:600}
.inh{color:#999}
.foot{margin-top:18px;text-align:center;font-size:12px;color:#bbb}
/* Multi-location rows */
.locs{display:flex;flex-direction:column;gap:4px}
.loc{display:flex;align-items:center;gap:5px;flex-wrap:wrap}
.uq-sm{color:#c62828;font-weight:700;font-size:11px}
.inh-sm{color:#bbb;font-size:11px}
.loc-count{color:#555;font-size:12px;line-height:1.6}
/* Pills */
.pill{display:inline-block;background:#e3f2fd;color:#1565c0;border-radius:20px;padding:2px 10px;font-size:12px;margin:2px 3px;cursor:default;user-select:none;white-space:nowrap}
.pill:hover{background:#bbdefb}
/* SP Group collapsible */
.grp-wrap{display:inline-block}
.grp-tog{cursor:pointer;font-weight:600;color:#0078d4;user-select:none;white-space:nowrap}
.grp-tog::before{content:'\25B6  ';font-size:10px}
.grp-tog.open::before{content:'\25BC  '}
.grp-members{display:none;margin-top:6px}
.grp-members.open{display:block}
/* Context menu */
.cmenu{position:fixed;background:#fff;border:1px solid #ddd;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.15);z-index:9999;min-width:190px;font-size:13px;display:none}
.cmenu.show{display:block}
.cmenu-item{padding:9px 16px;cursor:pointer;display:flex;align-items:center;gap:8px}
.cmenu-item:hover{background:#f0f7ff}
.cmenu-sep{border-top:1px solid #eee;margin:3px 0}
</style></head><body>
<div class="hdr">
  <h1>SharePoint Permissions Report</h1>
  <div class="sub">Site: <a href="$(EscHtml $SiteURL)">$(EscHtml $SiteTitle)</a> &bull; Generated: $generated</div>
</div>
<div class="cards">
  <div class="card"><div class="v">$count</div><div class="l">Total Entries</div></div>
  <div class="card"><div class="v">$uniqueCount</div><div class="l">Unique Permission Sets</div></div>
  <div class="card"><div class="v">$userCount</div><div class="l">Distinct Users / Groups</div></div>
</div>
<div class="wrap">
<div class="srch"><input type="text" id="q" placeholder="Filter results..." onkeyup="filterTable()"></div>
<table>
<thead><tr><th>Type</th><th>Name</th><th>Users / Members</th><th>Permission Level</th><th>Granted Through</th><th>Unique Permissions</th></tr></thead>
<tbody id="tbody">
$rows
</tbody></table></div>
<div class="foot">Generated by SharePoint Toolbox</div>

<!-- Context menu -->
<div class="cmenu" id="cmenu">
  <div class="cmenu-item" id="cmenu-copy">&#128203; Copier l'adresse email</div>
  <div class="cmenu-sep"></div>
  <div class="cmenu-item" id="cmenu-mailto">&#9993; Envoyer un email</div>
</div>

<script>
function toggleGrp(tog, id) {
  tog.classList.toggle('open');
  document.getElementById(id).classList.toggle('open');
}

var cmenu = document.getElementById('cmenu');
var cmenuEmail = '';

document.addEventListener('contextmenu', function(e) {
  var pill = e.target.closest ? e.target.closest('.pill') : null;
  if (!pill) { cmenu.classList.remove('show'); return; }
  e.preventDefault();
  cmenuEmail = pill.dataset.email || '';
  var x = e.clientX, y = e.clientY;
  cmenu.style.left = x + 'px'; cmenu.style.top = y + 'px';
  cmenu.classList.add('show');
  // Adjust if off-screen
  var r = cmenu.getBoundingClientRect();
  if (r.right > window.innerWidth)  cmenu.style.left = (x - r.width)  + 'px';
  if (r.bottom > window.innerHeight) cmenu.style.top = (y - r.height) + 'px';
});

document.addEventListener('click', function(e) {
  if (!e.target.closest('#cmenu')) cmenu.classList.remove('show');
});

document.getElementById('cmenu-copy').addEventListener('click', function() {
  if (!cmenuEmail) return;
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(cmenuEmail).catch(function() { fallbackCopy(cmenuEmail); });
  } else { fallbackCopy(cmenuEmail); }
  cmenu.classList.remove('show');
});

document.getElementById('cmenu-mailto').addEventListener('click', function() {
  if (cmenuEmail) window.location.href = 'mailto:' + cmenuEmail;
  cmenu.classList.remove('show');
});

function fallbackCopy(text) {
  var ta = document.createElement('textarea');
  ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
  document.body.appendChild(ta); ta.select();
  try { document.execCommand('copy'); } catch(e) {}
  document.body.removeChild(ta);
}
function filterTable(){
  var q=document.getElementById('q').value.toLowerCase();
  Array.from(document.getElementById('tbody').rows).forEach(function(r){
    r.classList.toggle('hidden', q && !r.innerText.toLowerCase().includes(q));
  });
}
</script>
</body></html>
"@
    $html | Out-File -FilePath $OutputPath -Encoding UTF8
}

#endregion

#region ===== HTML Export: Storage =====

function Export-StorageToHTML {
    param([array]$Data, [string]$SiteTitle, [string]$SiteURL, [string]$OutputPath)

    $generated  = Get-Date -Format 'dd/MM/yyyy HH:mm'
    $totalBytes        = ($Data | Measure-Object -Property SizeBytes -Sum).Sum
    $totalVersionBytes = ($Data | Where-Object { $_.VersionSizeBytes } | Measure-Object -Property VersionSizeBytes -Sum).Sum
    if (-not $totalVersionBytes) { $totalVersionBytes = 0 }
    $totalFiles = ($Data | Measure-Object -Property ItemCount -Sum).Sum
    $libCount   = $Data.Count

    # Shared toggle-ID counter - must be unique across all levels of nesting
    $script:togIdx = 0

    # Recursively builds folder rows; each folder with children gets its own toggle
    function Build-FolderRows([array]$Folders) {
        $html = ""
        foreach ($sf in ($Folders | Sort-Object SizeBytes -Descending)) {
            $myIdx     = $script:togIdx++
            $hasSubs   = $sf.SubFolders -and $sf.SubFolders.Count -gt 0
            $sfSzClass = if ($sf.SizeBytes -ge 10GB) { "sz-lg" } elseif ($sf.SizeBytes -ge 1GB) { "sz-md" } else { "sz-sm" }
            $sfLink    = if ($sf.URL) { "<a href='$(EscHtml $sf.URL)'>&#128193; $(EscHtml $sf.Name)</a>" } else { "&#128193; $(EscHtml $sf.Name)" }
            $togBtn    = if ($hasSubs) { "<button class='tog' onclick='toggle($myIdx)' title='$($sf.SubFolders.Count) subfolder(s)'>&#9654;</button>" } else { "<span class='tog-ph'></span>" }

            $sfVerBytes = if ($sf.VersionSizeBytes) { $sf.VersionSizeBytes } else { 0 }
            $sfVerPct   = if ($sf.SizeBytes -gt 0 -and $sfVerBytes -gt 0) { [math]::Round($sfVerBytes / $sf.SizeBytes * 100, 0) } else { 0 }
            $sfVerClass = if ($sfVerPct -ge 40) { "sz-lg" } elseif ($sfVerPct -ge 15) { "sz-md" } else { "" }
            $sfVerTxt   = if ($sfVerBytes -gt 0) { "<span class='$sfVerClass'>$(Format-Bytes $sfVerBytes)</span> <span class='pct'>($sfVerPct%)</span>" } else { "<span style='color:#bbb'>-</span>" }
            $html += "<tr><td><div class='lib-name'>$togBtn $sfLink</div></td>"
            $html += "<td class='num'>$($sf.ItemCount)</td>"
            $html += "<td class='num $sfSzClass'>$(Format-Bytes $sf.SizeBytes)</td>"
            $html += "<td class='num'>$sfVerTxt</td>"
            $html += "<td class='num'>$(EscHtml $sf.LastModified)</td></tr>`n"

            if ($hasSubs) {
                $html += "<tr id='sf-$myIdx' class='sf-row'><td colspan='5'><div class='sf-wrap'>"
                $html += "<table class='sf-tbl'><thead><tr><th>Folder</th><th style='text-align:right'>Files</th><th style='text-align:right'>Size</th><th style='text-align:right'>Versions</th><th style='text-align:right'>Last Modified</th></tr></thead>"
                $html += "<tbody>$(Build-FolderRows $sf.SubFolders)</tbody></table></div></td></tr>`n"
            }
        }
        return $html
    }

    $rows = ""
    foreach ($row in ($Data | Sort-Object SizeBytes -Descending)) {
        $myLibIdx   = $script:togIdx++
        $pct        = if ($totalBytes -gt 0) { [math]::Round($row.SizeBytes / $totalBytes * 100, 1) } else { 0 }
        $szClass    = if ($row.SizeBytes -ge 10GB) { "sz-lg" } elseif ($row.SizeBytes -ge 1GB) { "sz-md" } else { "sz-sm" }
        $hasFolders = $row.SubFolders -and $row.SubFolders.Count -gt 0
        $toggleBtn  = if ($hasFolders) { "<button class='tog' onclick='toggle($myLibIdx)' title='$($row.SubFolders.Count) subfolder(s)'>&#9654;</button>" } else { "<span class='tog-ph'></span>" }
        $nameCell   = if ($row.LibraryURL) { "<a href='$(EscHtml $row.LibraryURL)'>$(EscHtml $row.Library)</a>" } else { EscHtml $row.Library }

        $verBytes  = if ($row.VersionSizeBytes) { $row.VersionSizeBytes } else { 0 }
        $verPct    = if ($row.SizeBytes -gt 0 -and $verBytes -gt 0) { [math]::Round($verBytes / $row.SizeBytes * 100, 0) } else { 0 }
        $verClass  = if ($verPct -ge 40) { "sz-lg" } elseif ($verPct -ge 15) { "sz-md" } else { "" }
        $verTxt    = if ($verBytes -gt 0) { "<span class='$verClass'>$(Format-Bytes $verBytes)</span> <span class='pct'>($verPct%)</span>" } else { "<span style='color:#bbb'>-</span>" }
        $rows += "<tr><td><div class='lib-name'>$toggleBtn $nameCell</div></td>"
        $rows += "<td>$(EscHtml $row.SiteTitle)</td>"
        $rows += "<td class='num'>$($row.ItemCount)</td>"
        $rows += "<td class='num $szClass'>$(Format-Bytes $row.SizeBytes)</td>"
        $rows += "<td class='num'>$verTxt</td>"
        $rows += "<td><div class='bw'><div class='br' style='width:$pct%'></div><span class='pct'>$pct%</span></div></td>"
        $rows += "<td class='num'>$(EscHtml $row.LastModified)</td></tr>`n"

        if ($hasFolders) {
            $rows += "<tr id='sf-$myLibIdx' class='sf-row'><td colspan='7'><div class='sf-wrap'>"
            $rows += "<table class='sf-tbl'><thead><tr><th>Folder</th><th style='text-align:right'>Files</th><th style='text-align:right'>Size</th><th style='text-align:right'>Versions</th><th style='text-align:right'>Last Modified</th></tr></thead>"
            $rows += "<tbody>$(Build-FolderRows $row.SubFolders)</tbody></table></div></td></tr>`n"
        }
    }

$html = @"
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Storage - $(EscHtml $SiteTitle)</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;padding:30px}
h1{font-size:21px;font-weight:600;margin-bottom:6px}
.hdr{background:linear-gradient(135deg,#107c10,#0a5e0a);color:#fff;padding:22px 28px;border-radius:10px;margin-bottom:22px}
.hdr .sub{font-size:13px;opacity:.85;margin-top:4px}
.hdr a{color:#b8f0b8}
.cards{display:flex;gap:14px;margin-bottom:22px}
.card{background:#fff;border-radius:8px;padding:16px 20px;flex:1;box-shadow:0 1px 4px rgba(0,0,0,.08);text-align:center}
.card .v{font-size:26px;font-weight:700;color:#107c10}
.card .l{font-size:12px;color:#888;margin-top:3px}
.wrap{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.08);overflow:hidden}
table{border-collapse:collapse;width:100%}
th{background:#107c10;color:#fff;padding:11px 14px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
td{padding:9px 14px;font-size:13px;border-bottom:1px solid #f0f0f0;vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:#f5fdf5}
a{color:#107c10;text-decoration:none}
a:hover{text-decoration:underline}
.num{text-align:right;font-variant-numeric:tabular-nums}
.sz-lg{color:#c62828;font-weight:700}
.sz-md{color:#e65100;font-weight:600}
.sz-sm{color:#2e7d32}
.bw{display:flex;align-items:center;gap:8px;min-width:160px}
.br{height:10px;background:#107c10;border-radius:5px;min-width:2px}
.pct{font-size:12px;color:#666;white-space:nowrap}
.lib-name{display:flex;align-items:center;gap:6px}
.tog{background:none;border:none;cursor:pointer;color:#107c10;font-size:11px;padding:2px 4px;border-radius:3px;transition:transform .2s;flex-shrink:0;line-height:1}
.tog:hover{background:#e8f5e9}
.tog.open{transform:rotate(90deg)}
.tog-ph{display:inline-block;width:22px;flex-shrink:0}
.sf-row{display:none}
.sf-row>td{padding:0;background:#f3faf3;border-bottom:2px solid #c8e6c9}
.sf-wrap{padding:10px 16px 10px 46px}
.sf-tbl{border-collapse:collapse;width:100%}
.sf-tbl th{background:#d4edda;color:#1b5e20;padding:7px 12px;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
.sf-tbl td{padding:7px 12px;font-size:12px;border-bottom:1px solid #e8f5e9;color:#444}
.sf-tbl tr:last-child td{border-bottom:none}
.sf-tbl tr:hover td{background:#eaf7ea}
.sf-tbl a{color:#2e7d32}
.foot{margin-top:18px;text-align:center;font-size:12px;color:#bbb}
.srch{background:#fff;border-radius:8px;padding:10px 14px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
.srch input{width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:13px;outline:none}
.srch input:focus{border-color:#107c10}
.hidden{display:none}
</style>
<script>
function toggle(i){
  var row=document.getElementById('sf-'+i);
  var btn=row.previousElementSibling.querySelector('.tog');
  var visible=window.getComputedStyle(row).display!=='none';
  row.style.display=visible?'none':'table-row';
  btn.classList.toggle('open',!visible);
}
</script>
</head><body>
<div class="hdr">
  <h1>SharePoint Storage Metrics</h1>
  <div class="sub">Site: <a href="$(EscHtml $SiteURL)">$(EscHtml $SiteTitle)</a> &bull; Generated: $generated</div>
</div>
<div class="cards">
  <div class="card"><div class="v">$(Format-Bytes $totalBytes)</div><div class="l">Total Storage Used</div></div>
  <div class="card"><div class="v">$(Format-Bytes $totalVersionBytes)</div><div class="l">Version Storage</div></div>
  <div class="card"><div class="v">$totalFiles</div><div class="l">Total Files</div></div>
  <div class="card"><div class="v">$libCount</div><div class="l">Libraries / Sites Scanned</div></div>
</div>
<div class="wrap">
<div class="srch"><input type="text" id="q" placeholder="Filter results..." onkeyup="filterTable()"></div>
<table>
<thead><tr><th>Library</th><th>Site</th><th style="text-align:right">Files</th><th style="text-align:right">Size</th><th style="text-align:right">Versions</th><th>Share of Total</th><th style="text-align:right">Last Modified</th></tr></thead>
<tbody id="tbody">
$rows
</tbody></table></div>
<div class="foot">Generated by SharePoint Toolbox</div>
<script>
function filterTable(){
  var q=document.getElementById('q').value.toLowerCase();
  Array.from(document.getElementById('tbody').rows).forEach(function(r){
    r.classList.toggle('hidden', q && !r.innerText.toLowerCase().includes(q));
  });
}
</script>
</body></html>
"@
    $html | Out-File -FilePath $OutputPath -Encoding UTF8
}

#endregion

#region ===== PnP: Permissions =====

Function Get-PnPPermissions([Microsoft.SharePoint.Client.SecurableObject]$Object) {
    Switch ($Object.TypedObject.ToString()) {
        "Microsoft.SharePoint.Client.Web" {
            $ObjectType  = "Site"
            $ObjectURL   = $Object.URL
            $ObjectTitle = $Object.Title
        }
        "Microsoft.SharePoint.Client.ListItem" {
            $ObjectType  = "Folder"
            $Folder      = Get-PnPProperty -ClientObject $Object -Property Folder
            $ObjectTitle = $Object.Folder.Name
            $ObjectURL   = $("{0}{1}" -f $Web.Url.Replace($Web.ServerRelativeUrl,''), $Object.Folder.ServerRelativeUrl)
        }
        Default {
            $ObjectType  = $Object.BaseType
            $ObjectTitle = $Object.Title
            $RootFolder  = Get-PnPProperty -ClientObject $Object -Property RootFolder
            $ObjectURL   = $("{0}{1}" -f $Web.Url.Replace($Web.ServerRelativeUrl,''), $RootFolder.ServerRelativeUrl)
        }
    }

    Get-PnPProperty -ClientObject $Object -Property HasUniqueRoleAssignments, RoleAssignments
    $HasUniquePermissions = $Object.HasUniqueRoleAssignments

    Foreach ($RoleAssignment in $Object.RoleAssignments) {
        Get-PnPProperty -ClientObject $RoleAssignment -Property RoleDefinitionBindings, Member
        $PermissionType   = $RoleAssignment.Member.PrincipalType
        $PermissionLevels = ($RoleAssignment.RoleDefinitionBindings | Select-Object -ExpandProperty Name |
            Where-Object { $_ -ne "Limited Access" }) -join "; "
        If ($PermissionLevels.Length -eq 0) { Continue }

        $entry = [PSCustomObject]@{
            Object             = $ObjectType
            Title              = $ObjectTitle
            URL                = $ObjectURL
            HasUniquePermissions = $HasUniquePermissions
            Permissions        = $PermissionLevels
            GrantedThrough     = ""
            Type               = $PermissionType
            Users              = ""
            UserLogins         = ""
        }

        If ($PermissionType -eq "SharePointGroup") {
            $grpLogin = $RoleAssignment.Member.LoginName
            If ($grpLogin -match '^SharingLinks\.' -or $grpLogin -eq 'Limited Access System Group') { Continue }
            $GroupMembers = Get-PnPGroupMember -Identity $grpLogin
            If ($GroupMembers.count -eq 0) { Continue }
            $filteredMembers = @($GroupMembers | Where-Object { $_.Title -ne "System Account" })
            $GroupUsers  = ($filteredMembers | Select-Object -ExpandProperty Title) -join "; "
            $GroupEmails = ($filteredMembers | Select-Object -ExpandProperty Email) -join "; "
            If ($GroupUsers.Length -eq 0) { Continue }
            $entry.Users         = $GroupUsers
            $entry.UserLogins    = $GroupEmails
            $entry.GrantedThrough = "SharePoint Group: $grpLogin"
        }
        Else {
            $entry.Users         = $RoleAssignment.Member.Title
            $entry.UserLogins    = $RoleAssignment.Member.Email
            $entry.GrantedThrough = "Direct Permissions"
        }

        $script:AllPermissions += $entry
    }
}

Function Generate-PnPSitePermissionRpt {
    [cmdletbinding()]
    Param (
        [String] $SiteURL,
        [String] $ReportFile,
        [switch] $Recursive,
        [switch] $ScanFolders,
        [switch] $IncludeInheritedPermissions
    )
    Try {
        Write-Log "Connecting to SharePoint... (browser window will open)" "Yellow"
        [System.Windows.Forms.Application]::DoEvents()
        Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $script:pnpCiD
        $Web = Get-PnPWeb
        [System.Windows.Forms.Application]::DoEvents()

        Write-Log "Getting Site Collection Administrators..." "Yellow"
        $SiteAdmins = Get-PnPSiteCollectionAdmin
        $script:AllPermissions += [PSCustomObject]@{
            Object               = "Site Collection"
            Title                = $Web.Title
            URL                  = $Web.URL
            HasUniquePermissions = "TRUE"
            Users                = ($SiteAdmins | Select-Object -ExpandProperty Title) -join "; "
            UserLogins           = ($SiteAdmins | Select-Object -ExpandProperty Email) -join "; "
            Type                 = "Site Collection Administrators"
            Permissions          = "Site Owner"
            GrantedThrough       = "Direct Permissions"
        }

        Function Get-PnPFolderPermission([Microsoft.SharePoint.Client.List]$List) {
            Write-Log "`t`tScanning folders in: $($List.Title)" "Yellow"
            $ListItems = Get-PnPListItem -List $List -PageSize 2000
            $Folders   = $ListItems | Where-Object {
                ($_.FileSystemObjectType -eq "Folder") -and
                ($_.FieldValues.FileLeafRef -ne "Forms") -and
                (-Not($_.FieldValues.FileLeafRef.StartsWith("_")))
            }
            # Apply folder depth filter (999 = maximum / no limit)
            If ($script:PermFolderDepth -lt 999) {
                $rf      = Get-PnPProperty -ClientObject $List -Property RootFolder
                $rootSrl = $rf.ServerRelativeUrl.TrimEnd('/')
                $Folders = $Folders | Where-Object {
                    $relPath = $_.FieldValues.FileRef.Substring($rootSrl.Length).TrimStart('/')
                    ($relPath -split '/').Count -le $script:PermFolderDepth
                }
            }
            $i = 0
            ForEach ($Folder in $Folders) {
                If ($IncludeInheritedPermissions) { Get-PnPPermissions -Object $Folder }
                Else {
                    If ((Get-PnPProperty -ClientObject $Folder -Property HasUniqueRoleAssignments) -eq $true) {
                        Get-PnPPermissions -Object $Folder
                    }
                }
                $i++
                [System.Windows.Forms.Application]::DoEvents()
            }
        }

        Function Get-PnPListPermission([Microsoft.SharePoint.Client.Web]$Web) {
            $Lists         = Get-PnPProperty -ClientObject $Web -Property Lists
            $ExcludedLists = @(
                "Access Requests","App Packages","appdata","appfiles","Apps in Testing","Cache Profiles",
                "Composed Looks","Content and Structure Reports","Content type publishing error log",
                "Converted Forms","Device Channels","Form Templates","fpdatasources",
                "Get started with Apps for Office and SharePoint","List Template Gallery",
                "Long Running Operation Status","Maintenance Log Library","Images","site collection images",
                "Master Docs","Master Page Gallery","MicroFeed","NintexFormXml","Quick Deploy Items",
                "Relationships List","Reusable Content","Reporting Metadata","Reporting Templates",
                "Search Config List","Site Assets","Preservation Hold Library","Site Pages",
                "Solution Gallery","Style Library","Suggested Content Browser Locations","Theme Gallery",
                "TaxonomyHiddenList","User Information List","Web Part Gallery","wfpub","wfsvc",
                "Workflow History","Workflow Tasks","Pages"
            )
            $c = 0
            ForEach ($List in $Lists) {
                If ($List.Hidden -eq $false -and $ExcludedLists -notcontains $List.Title) {
                    $c++
                    Write-Log "`tList ($c/$($Lists.Count)): $($List.Title)" "Cyan"
                    [System.Windows.Forms.Application]::DoEvents()
                    If ($ScanFolders) { Get-PnPFolderPermission -List $List }
                    If ($IncludeInheritedPermissions) { Get-PnPPermissions -Object $List }
                    Else {
                        If ((Get-PnPProperty -ClientObject $List -Property HasUniqueRoleAssignments) -eq $true) {
                            Get-PnPPermissions -Object $List
                        }
                    }
                }
            }
        }

        Function Get-PnPWebPermission([Microsoft.SharePoint.Client.Web]$Web) {
            Write-Log "Processing web: $($Web.URL)" "Yellow"
            [System.Windows.Forms.Application]::DoEvents()
            Get-PnPPermissions -Object $Web
            Write-Log "`tScanning lists and libraries..." "Yellow"
            Get-PnPListPermission($Web)
            If ($Recursive) {
                $Subwebs = Get-PnPProperty -ClientObject $Web -Property Webs
                Foreach ($Subweb in $web.Webs) {
                    If ($IncludeInheritedPermissions) { Get-PnPWebPermission($Subweb) }
                    Else {
                        If ((Get-PnPProperty -ClientObject $SubWeb -Property HasUniqueRoleAssignments) -eq $true) {
                            Get-PnPWebPermission($Subweb)
                        }
                    }
                }
            }
        }

        Get-PnPWebPermission $Web

        # Export based on chosen format
        Write-Log "Writing output file..." "Yellow"
        If ($script:PermFormat -eq "HTML") {
            $outPath = [System.IO.Path]::ChangeExtension($ReportFile, ".html")
            Export-PermissionsToHTML -Data $script:AllPermissions -SiteTitle $Web.Title -SiteURL $Web.URL -OutputPath $outPath
            $script:PermOutputFile = $outPath
        }
        Else {
            $mergedPerms = Merge-PermissionRows $script:AllPermissions
            $mergedPerms | ForEach-Object {
                $locs = @($_.Locations)
                $uqN  = ($locs | Where-Object { $_.HasUniquePermissions -eq 'TRUE' -or $_.HasUniquePermissions -eq $true }).Count
                [PSCustomObject]@{
                    Object               = ($locs | Select-Object -ExpandProperty Object -Unique) -join ', '
                    Title                = ($locs | Select-Object -ExpandProperty Title) -join ' | '
                    URL                  = ($locs | Select-Object -ExpandProperty URL) -join ' | '
                    HasUniquePermissions = if ($locs.Count -eq 1) { $locs[0].HasUniquePermissions } else { "$uqN/$($locs.Count) uniques" }
                    Users                = $_.Users
                    UserLogins           = $_.UserLogins
                    Type                 = $_.Type
                    Permissions          = $_.Permissions
                    GrantedThrough       = $_.GrantedThrough
                }
            } | Export-Csv -Path $ReportFile -NoTypeInformation
            $script:PermOutputFile = $ReportFile
        }

        Write-Log "Report generated successfully!" "LightGreen"
    }
    Catch {
        Write-Log "Error: $($_.Exception.Message)" "Red"
        throw
    }
}

#endregion

#region ===== PnP: Storage Metrics =====

function Get-SiteStorageMetrics {
    param([string]$SiteURL, [switch]$IncludeSubsites, [switch]$PerLibrary)

    $script:storageResults = @()

    # Recursively collects subfolders up to $MaxDepth levels deep
    function Collect-FolderStorage([string]$FolderSiteRelUrl, [string]$WebBaseUrl, [int]$CurrentDepth) {
        if ($CurrentDepth -ge $script:FolderDepth) { return @() }
        $result = @()
        try {
            $items = Get-PnPFolderItem -FolderSiteRelativeUrl $FolderSiteRelUrl -ItemType Folder
            foreach ($fi in $items) {
                $fiSrl = "$FolderSiteRelUrl/$($fi.Name)"
                try {
                    $fiSm     = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $fiSrl
                    $children = Collect-FolderStorage -FolderSiteRelUrl $fiSrl -WebBaseUrl $WebBaseUrl -CurrentDepth ($CurrentDepth + 1)
                    $result  += [PSCustomObject]@{
                        Name         = $fi.Name
                        URL          = "$($WebBaseUrl.TrimEnd('/'))/$fiSrl"
                        ItemCount    = $fiSm.TotalFileCount
                        SizeBytes    = $fiSm.TotalSize
                        LastModified = if ($fiSm.LastModified) { $fiSm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
                        SubFolders   = $children
                    }
                } catch {}
            }
        } catch {}
        return $result
    }

    function Collect-WebStorage([string]$WebUrl) {
        # Connect to this specific web (token is cached, no extra browser prompts)
        Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $script:pnpCiD
        $webObj = Get-PnPWeb
        $wTitle = $webObj.Title
        $wUrl   = $webObj.Url
        # ServerRelativeUrl of the web (e.g. "/sites/MySite") - used to compute site-relative paths
        $wSrl   = $webObj.ServerRelativeUrl.TrimEnd('/')

        if ($PerLibrary) {
            $lists = Get-PnPList | Where-Object { $_.BaseType -eq "DocumentLibrary" -and !$_.Hidden }
            foreach ($list in $lists) {
                $rf = Get-PnPProperty -ClientObject $list -Property RootFolder
                try {
                    # Convert server-relative (/sites/X/LibName) to site-relative (LibName)
                    $siteRelUrl = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
                    $sm     = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $siteRelUrl
                    $libUrl = "$($wUrl.TrimEnd('/'))/$siteRelUrl"

                    # Recursively collect subfolders up to the configured depth
                    $subFolders = Collect-FolderStorage -FolderSiteRelUrl $siteRelUrl -WebBaseUrl $wUrl -CurrentDepth 0

                    $script:storageResults += [PSCustomObject]@{
                        SiteTitle    = $wTitle
                        SiteURL      = $wUrl
                        Library      = $list.Title
                        LibraryURL   = $libUrl
                        ItemCount    = $sm.TotalFileCount
                        SizeBytes    = $sm.TotalSize
                        SizeMB       = [math]::Round($sm.TotalSize / 1MB, 1)
                        SizeGB       = [math]::Round($sm.TotalSize / 1GB, 3)
                        LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
                        SubFolders   = $subFolders
                    }
                    Write-Log "`t  $($list.Title): $(Format-Bytes $sm.TotalSize) ($($sm.TotalFileCount) files, $($subFolders.Count) folders)" "Cyan"
                }
                catch { Write-Log "`t  Skipped '$($list.Title)': $($_.Exception.Message)" "DarkGray" }
            }
        }
        else {
            try {
                $sm = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl "/"
                $script:storageResults += [PSCustomObject]@{
                    SiteTitle    = $wTitle
                    SiteURL      = $wUrl
                    Library      = "(All Libraries)"
                    LibraryURL   = $wUrl
                    ItemCount    = $sm.TotalFileCount
                    SizeBytes    = $sm.TotalSize
                    SizeMB       = [math]::Round($sm.TotalSize / 1MB, 1)
                    SizeGB       = [math]::Round($sm.TotalSize / 1GB, 3)
                    LastModified = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
                }
                Write-Log "`t${wTitle}: $(Format-Bytes $sm.TotalSize) ($($sm.TotalFileCount) files)" "Cyan"
            }
            catch { Write-Log "`tSkipped '${wTitle}': $($_.Exception.Message)" "DarkGray" }
        }

        if ($IncludeSubsites) {
            $subWebs = Get-PnPSubWeb
            foreach ($sub in $subWebs) {
                Write-Log "`tProcessing subsite: $($sub.Title)" "Yellow"
                Collect-WebStorage $sub.Url
                # Reconnect to parent so subsequent sibling subsites resolve correctly
                Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $script:pnpCiD
            }
        }
    }

    Write-Log "Collecting storage metrics for: $SiteURL" "Yellow"
    Collect-WebStorage $SiteURL
    return $script:storageResults
}

#endregion

#region ===== File Search =====

function Export-SearchResultsToHTML {
    param([array]$Results, [string]$KQL, [string]$SiteUrl)
    $rows = ""
    foreach ($r in $Results) {
        $title   = EscHtml ($r.Title     -replace '^$', '(sans titre)')
        $path    = EscHtml ($r.Path      -replace '^$', '')
        $ext     = EscHtml ($r.FileExtension -replace '^$', '')
        $created = EscHtml ($r.Created   -replace '^$', '')
        $modif   = EscHtml ($r.LastModifiedTime -replace '^$', '')
        $author  = EscHtml ($r.Author    -replace '^$', '')
        $modby   = EscHtml ($r.ModifiedBy -replace '^$', '')
        $sizeB   = [long]($r.Size -replace '[^0-9]','0' -replace '^$','0')
        $sizeStr = EscHtml (Format-Bytes $sizeB)
        $href    = EscHtml $r.Path
        $rows += "<tr><td><a href='$href' target='_blank'>$title</a></td>"
        $rows += "<td><span class='ext'>$ext</span></td>"
        $rows += "<td data-ts='$(EscHtml ($r.Created -replace '^$','0'))'>$created</td>"
        $rows += "<td data-ts='$(EscHtml ($r.LastModifiedTime -replace '^$','0'))'>$modif</td>"
        $rows += "<td>$author</td><td>$modby</td>"
        $rows += "<td style='text-align:right' data-size='$sizeB'>$sizeStr</td></tr>`n"
    }
    $count = $Results.Count
    $date  = Get-Date -Format "dd/MM/yyyy HH:mm"
    $kqlEsc = EscHtml $KQL
    $siteEsc = EscHtml $SiteUrl

$html = @"
<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Recherche de fichiers</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;padding:30px}
h1{font-size:21px;font-weight:600;margin-bottom:6px}
.hdr{background:#0078d4;color:#fff;padding:22px 28px;border-radius:10px;margin-bottom:22px}
.hdr .sub{font-size:12px;opacity:.8;margin-top:6px;word-break:break-all}
.cards{display:flex;gap:14px;margin-bottom:22px}
.card{background:#fff;border-radius:8px;padding:16px 20px;flex:1;box-shadow:0 1px 4px rgba(0,0,0,.08);text-align:center}
.card .v{font-size:26px;font-weight:700;color:#0078d4}
.card .l{font-size:12px;color:#888;margin-top:3px}
.kql{background:#fff;border-radius:8px;padding:12px 16px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.08);font-family:Consolas,monospace;font-size:12px;color:#333;word-break:break-all}
.kql strong{color:#0078d4}
.wrap{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.08);overflow:hidden}
.srch{padding:12px 16px;border-bottom:1px solid #eee}
.srch input{width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:13px;outline:none}
.srch input:focus{border-color:#0078d4}
table{border-collapse:collapse;width:100%}
th{background:#0078d4;color:#fff;padding:10px 14px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;cursor:pointer}
th:hover{background:#005fa3}
td{padding:8px 14px;font-size:12px;border-bottom:1px solid #f0f0f0;vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:#f5f9ff}
a{color:#0078d4;text-decoration:none}
a:hover{text-decoration:underline}
.ext{background:#e8f0fe;color:#1a73e8;border-radius:3px;padding:2px 6px;font-size:11px;font-family:Consolas,monospace;font-weight:600}
.si{font-size:10px;opacity:.7}
.footer{margin-top:18px;font-size:11px;color:#999;text-align:center}
.hidden{display:none}
</style>
</head><body>
<div class="hdr">
<h1>Recherche de fichiers SharePoint</h1>
<div class="sub">Site : <a href="$siteEsc" style="color:#cce4ff">$siteEsc</a> &mdash; Genere le $date</div>
</div>
<div class="cards">
<div class="card"><div class="v">$count</div><div class="l">Fichiers trouves</div></div>
</div>
<div class="kql"><strong>Requete KQL :</strong> $kqlEsc</div>
<div class="wrap">
<div class="srch"><input type="text" id="q" placeholder="Filtrer les resultats..." onkeyup="filterTable()"></div>
<table id="tbl">
<thead><tr>
<th onclick="sortTable(0)" data-col="0">Nom du fichier <span class="si"></span></th>
<th onclick="sortTable(1)" data-col="1">Extension <span class="si"></span></th>
<th onclick="sortTable(2)" data-col="2">Cree le <span class="si"></span></th>
<th onclick="sortTable(3)" data-col="3">Modifie le <span class="si"></span></th>
<th onclick="sortTable(4)" data-col="4">Cree par <span class="si"></span></th>
<th onclick="sortTable(5)" data-col="5">Modifie par <span class="si"></span></th>
<th onclick="sortTable(6)" data-col="6" style="text-align:right">Taille <span class="si"></span></th>
</tr></thead>
<tbody id="tbody">$rows</tbody>
</table></div>
<div class="footer">SharePoint Exporter &mdash; Recherche de fichiers</div>
<script>
var sortDir={};
function sortTable(c){
  var tb=document.getElementById('tbody'),rows=Array.from(tb.rows).filter(function(r){return !r.classList.contains('hidden');});
  var allRows=Array.from(tb.rows);
  var asc=sortDir[c]=!sortDir[c];
  rows.sort(function(a,b){
    if(c===6){
      return asc
        ? (parseFloat(a.cells[c].dataset.size||0)-parseFloat(b.cells[c].dataset.size||0))
        : (parseFloat(b.cells[c].dataset.size||0)-parseFloat(a.cells[c].dataset.size||0));
    }
    if(c===2||c===3){
      var dx=new Date(a.cells[c].dataset.ts||0).getTime()||0;
      var dy=new Date(b.cells[c].dataset.ts||0).getTime()||0;
      return asc?dx-dy:dy-dx;
    }
    var x=a.cells[c].innerText.trim(),y=b.cells[c].innerText.trim();
    return asc?x.localeCompare(y,'fr',{numeric:true}):y.localeCompare(x,'fr',{numeric:true});
  });
  /* re-append sorted visible rows, keep hidden rows at the end */
  var hidden=allRows.filter(function(r){return r.classList.contains('hidden');});
  rows.concat(hidden).forEach(function(r){tb.appendChild(r);});
  /* update sort indicators */
  document.querySelectorAll('th[data-col] .si').forEach(function(s){s.textContent='';});
  var th=document.querySelector('th[data-col="'+c+'"] .si');
  if(th) th.textContent=asc?' \u25b2':' \u25bc';
}
function filterTable(){
  var q=document.getElementById('q').value.toLowerCase();
  Array.from(document.getElementById('tbody').rows).forEach(function(r){
    r.classList.toggle('hidden', q && !r.innerText.toLowerCase().includes(q));
  });
}
</script>
</body></html>
"@
    return $html
}

function Export-DuplicatesToHTML {
    param([array]$Groups, [string]$Mode, [string]$SiteUrl)

    $totalItems  = ($Groups | ForEach-Object { $_.Items.Count } | Measure-Object -Sum).Sum
    $totalGroups = $Groups.Count
    $date        = Get-Date -Format "dd/MM/yyyy HH:mm"
    $modeLabel   = if ($Mode -eq "Folders") { "Dossiers" } else { "Fichiers" }
    $siteEsc     = EscHtml $SiteUrl

    # Build group cards
    $cards = ""
    $gIdx  = 0
    foreach ($grp in $Groups | Sort-Object { $_.Items.Count } -Descending) {
        $gIdx++
        $name    = EscHtml $grp.Name
        $count   = $grp.Items.Count
        $items   = $grp.Items

        # Determine which columns to show
        $hasSz   = $items[0].PSObject.Properties.Name -contains "SizeBytes"
        $hasCr   = $items[0].PSObject.Properties.Name -contains "Created"
        $hasMod  = $items[0].PSObject.Properties.Name -contains "Modified"
        $hasSub  = $items[0].PSObject.Properties.Name -contains "FolderCount"
        $hasFil  = $items[0].PSObject.Properties.Name -contains "FileCount"

        # Helper: check if all values in column are identical → green, else orange
        $allSame = { param($prop)
            $vals = @($items | ForEach-Object { $_.$prop })
            ($vals | Select-Object -Unique).Count -le 1
        }

        $szSame  = if ($hasSz)  { & $allSame "SizeBytes" }         else { $true }
        $crSame  = if ($hasCr)  { & $allSame "CreatedDay" }        else { $true }
        $modSame = if ($hasMod) { & $allSame "ModifiedDay" }       else { $true }
        $subSame = if ($hasSub) { & $allSame "FolderCount" }       else { $true }
        $filSame = if ($hasFil) { & $allSame "FileCount" }         else { $true }

        $thSz  = if ($hasSz)  { "<th class='$(if($szSame){"ok"}else{"diff"})'>Taille</th>" } else {""}
        $thCr  = if ($hasCr)  { "<th class='$(if($crSame){"ok"}else{"diff"})'>Cree le</th>" } else {""}
        $thMod = if ($hasMod) { "<th class='$(if($modSame){"ok"}else{"diff"})'>Modifie le</th>" } else {""}
        $thSub = if ($hasSub) { "<th class='$(if($subSame){"ok"}else{"diff"})'>Sous-dossiers</th>" } else {""}
        $thFil = if ($hasFil) { "<th class='$(if($filSame){"ok"}else{"diff"})'>Fichiers</th>" } else {""}

        $rows = ""
        foreach ($item in $items) {
            $nm   = EscHtml $item.Name
            $path = EscHtml $item.Path
            $href = EscHtml $item.Path
            $lib  = EscHtml $item.Library

            $tdSz  = if ($hasSz)  {
                $cls = if ($szSame) { "ok" } else { "diff" }
                "<td class='$cls r'>$(EscHtml (Format-Bytes ([long]$item.SizeBytes)))</td>"
            } else {""}
            $tdCr  = if ($hasCr)  {
                $cls = if ($crSame) { "ok" } else { "diff" }
                "<td class='$cls'>$(EscHtml $item.Created)</td>"
            } else {""}
            $tdMod = if ($hasMod) {
                $cls = if ($modSame) { "ok" } else { "diff" }
                "<td class='$cls'>$(EscHtml $item.Modified)</td>"
            } else {""}
            $tdSub = if ($hasSub) {
                $cls = if ($subSame) { "ok" } else { "diff" }
                "<td class='$cls r'>$($item.FolderCount)</td>"
            } else {""}
            $tdFil = if ($hasFil) {
                $cls = if ($filSame) { "ok" } else { "diff" }
                "<td class='$cls r'>$($item.FileCount)</td>"
            } else {""}

            $rows += "<tr>"
            $rows += "<td><a href='$href' target='_blank'>$nm</a><br><small class='lib'>$lib</small></td>"
            $rows += "$tdSz$tdCr$tdMod$tdSub$tdFil"
            $rows += "</tr>`n"
        }

        $diffBadge = if ($szSame -and $crSame -and $modSame -and $subSame -and $filSame) {
            "<span class='badge-id'>Identiques</span>"
        } else {
            "<span class='badge-diff'>Differences detectees</span>"
        }

        $cards += @"
<div class="grp-card" id="g$gIdx">
<div class="grp-hdr" onclick="toggleGrp($gIdx)">
  <span class="grp-name">$name</span>
  <span class="grp-count">$count occurrences</span>
  $diffBadge
  <span class="grp-arrow" id="arr$gIdx">&#9660;</span>
</div>
<div class="grp-body" id="gb$gIdx">
<table><thead><tr>
<th>Chemin</th>$thSz$thCr$thMod$thSub$thFil
</tr></thead><tbody>$rows</tbody></table>
</div></div>
"@
    }

$html = @"
<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Doublons $modeLabel - SharePoint</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;padding:30px}
h1{font-size:21px;font-weight:600;margin-bottom:6px}
.hdr{background:#0078d4;color:#fff;padding:22px 28px;border-radius:10px;margin-bottom:22px}
.hdr .sub{font-size:12px;opacity:.8;margin-top:6px}
.cards{display:flex;gap:14px;margin-bottom:22px}
.card{background:#fff;border-radius:8px;padding:16px 20px;flex:1;box-shadow:0 1px 4px rgba(0,0,0,.08);text-align:center}
.card .v{font-size:26px;font-weight:700;color:#0078d4}
.card .l{font-size:12px;color:#888;margin-top:3px}
.srch{background:#fff;border-radius:8px;padding:10px 14px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
.srch input{width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:13px;outline:none}
.srch input:focus{border-color:#0078d4}
.grp-card{background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-bottom:12px;overflow:hidden}
.grp-hdr{display:flex;align-items:center;gap:10px;padding:12px 16px;cursor:pointer;background:#fff;border-bottom:1px solid #eee;user-select:none}
.grp-hdr:hover{background:#f5f9ff}
.grp-name{font-weight:600;font-size:14px;flex:1}
.grp-count{font-size:12px;color:#888;white-space:nowrap}
.grp-arrow{font-size:11px;color:#aaa;transition:transform .2s}
.grp-body{overflow-x:auto;max-height:500px;overflow-y:auto}
.grp-body.collapsed{display:none}
table{border-collapse:collapse;width:100%;min-width:500px}
th{background:#e8f0fe;color:#1a56a0;padding:8px 12px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.4px;white-space:nowrap}
th.ok{background:#e6f4ea;color:#1e7e34}
th.diff{background:#fff3cd;color:#856404}
td{padding:7px 12px;font-size:12px;border-bottom:1px solid #f0f0f0;vertical-align:middle}
td.ok{background:#f0faf2}
td.diff{background:#fff8e6}
td.r{text-align:right}
tr:last-child td{border-bottom:none}
a{color:#0078d4;text-decoration:none}
a:hover{text-decoration:underline}
small.lib{color:#999;font-size:10px}
.badge-id{background:#e6f4ea;color:#1e7e34;border-radius:20px;padding:2px 8px;font-size:11px;font-weight:600}
.badge-diff{background:#fff3cd;color:#856404;border-radius:20px;padding:2px 8px;font-size:11px;font-weight:600}
.hidden{display:none}
.footer{margin-top:20px;font-size:11px;color:#999;text-align:center}
</style>
</head><body>
<div class="hdr">
<h1>Doublons de $modeLabel &mdash; SharePoint</h1>
<div class="sub">Site : <a href="$siteEsc" style="color:#cce4ff">$siteEsc</a> &mdash; Genere le $date</div>
</div>
<div class="cards">
<div class="card"><div class="v">$totalGroups</div><div class="l">Groupes de doublons</div></div>
<div class="card"><div class="v">$totalItems</div><div class="l">$modeLabel en double (total)</div></div>
</div>
<div class="srch"><input type="text" id="q" placeholder="Filtrer par nom..." onkeyup="filterGroups()"></div>
$cards
<div class="footer">SharePoint Exporter &mdash; Detection de doublons</div>
<script>
function toggleGrp(i){
  var b=document.getElementById('gb'+i);
  var a=document.getElementById('arr'+i);
  b.classList.toggle('collapsed');
  a.style.transform=b.classList.contains('collapsed')?'rotate(-90deg)':'';
}
function filterGroups(){
  var q=document.getElementById('q').value.toLowerCase();
  document.querySelectorAll('.grp-card').forEach(function(c){
    var nm=c.querySelector('.grp-name').textContent.toLowerCase();
    c.classList.toggle('hidden', q && !nm.includes(q));
  });
}
</script>
</body></html>
"@
    return $html
}

#endregion

#region ===== Transfer =====

function Export-TransferVerifyToHTML {
    param([array]$Results, [string]$SrcSite, [string]$SrcLib, [string]$DstSite, [string]$DstLib)

    $okCount       = @($Results | Where-Object { $_.Status -eq "OK" }).Count
    $missingCount  = @($Results | Where-Object { $_.Status -eq "MISSING" }).Count
    $mismatchCount = @($Results | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count
    $extraCount    = @($Results | Where-Object { $_.Status -eq "EXTRA" }).Count
    $totalCount    = $Results.Count
    $date          = Get-Date -Format "dd/MM/yyyy HH:mm"

    $rows = ""
    foreach ($r in $Results) {
        $statusClass = switch ($r.Status) {
            "OK"            { "status-ok" }
            "MISSING"       { "status-missing" }
            "SIZE_MISMATCH" { "status-mismatch" }
            "EXTRA"         { "status-extra" }
            default         { "" }
        }
        $statusLabel = switch ($r.Status) {
            "OK"            { "OK" }
            "MISSING"       { "MISSING" }
            "SIZE_MISMATCH" { "SIZE MISMATCH" }
            "EXTRA"         { "EXTRA" }
            default         { $r.Status }
        }
        $name    = EscHtml $r.Name
        $relPath = EscHtml $r.RelativePath
        $srcSize = if ($null -ne $r.SourceSize) { EscHtml (Format-Bytes $r.SourceSize) } else { "-" }
        $dstSize = if ($null -ne $r.DestSize)   { EscHtml (Format-Bytes $r.DestSize) }   else { "-" }
        $srcRaw  = if ($null -ne $r.SourceSize) { $r.SourceSize } else { 0 }
        $dstRaw  = if ($null -ne $r.DestSize)   { $r.DestSize }   else { 0 }
        $rows += "<tr class='$statusClass'>"
        $rows += "<td><span class='badge $statusClass'>$statusLabel</span></td>"
        $rows += "<td>$name</td>"
        $rows += "<td>$relPath</td>"
        $rows += "<td style='text-align:right' data-size='$srcRaw'>$srcSize</td>"
        $rows += "<td style='text-align:right' data-size='$dstRaw'>$dstSize</td></tr>`n"
    }

    $srcEsc = EscHtml "$SrcSite/$SrcLib"
    $dstEsc = EscHtml "$DstSite/$DstLib"

$html = @"
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Transfer Verification Report</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;padding:30px}
h1{font-size:21px;font-weight:600;margin-bottom:6px}
.hdr{background:#0078d4;color:#fff;padding:22px 28px;border-radius:10px;margin-bottom:22px}
.hdr .sub{font-size:12px;opacity:.8;margin-top:6px;word-break:break-all}
.cards{display:flex;gap:14px;margin-bottom:22px;flex-wrap:wrap}
.card{background:#fff;border-radius:8px;padding:16px 20px;flex:1;min-width:110px;box-shadow:0 1px 4px rgba(0,0,0,.08);text-align:center}
.card .v{font-size:26px;font-weight:700}
.card .l{font-size:12px;color:#888;margin-top:3px}
.v-total{color:#0078d4} .v-ok{color:#28a745} .v-missing{color:#dc3545} .v-mismatch{color:#fd7e14} .v-extra{color:#17a2b8}
.wrap{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.08);overflow:hidden}
.srch{padding:12px 16px;border-bottom:1px solid #eee}
.srch input{width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:13px;outline:none}
.srch input:focus{border-color:#0078d4}
table{border-collapse:collapse;width:100%}
th{background:#0078d4;color:#fff;padding:10px 14px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;cursor:pointer}
th:hover{background:#005fa3}
td{padding:8px 14px;font-size:12px;border-bottom:1px solid #f0f0f0;vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:#f5f9ff}
.badge{display:inline-block;padding:2px 8px;border-radius:3px;font-size:11px;font-weight:600;text-transform:uppercase}
.status-ok{background-color:rgba(40,167,69,.10)} .status-ok .badge{background:#d4edda;color:#155724}
.status-missing{background-color:rgba(220,53,69,.08)} .status-missing .badge{background:#f8d7da;color:#721c24}
.status-mismatch{background-color:rgba(253,126,20,.08)} .status-mismatch .badge{background:#fff3cd;color:#856404}
.status-extra{background-color:rgba(23,162,184,.08)} .status-extra .badge{background:#cce5ff;color:#004085}
.si{font-size:10px;opacity:.7}
.footer{margin-top:18px;font-size:11px;color:#999;text-align:center}
.hidden{display:none}
</style>
</head><body>
<div class="hdr">
<h1>Transfer Verification Report</h1>
<div class="sub">Source: $srcEsc &rarr; Destination: $dstEsc &mdash; $date</div>
</div>
<div class="cards">
<div class="card"><div class="v v-total">$totalCount</div><div class="l">Total files</div></div>
<div class="card"><div class="v v-ok">$okCount</div><div class="l">Matched</div></div>
<div class="card"><div class="v v-missing">$missingCount</div><div class="l">Missing</div></div>
<div class="card"><div class="v v-mismatch">$mismatchCount</div><div class="l">Size mismatch</div></div>
<div class="card"><div class="v v-extra">$extraCount</div><div class="l">Extra at dest</div></div>
</div>
<div class="wrap">
<div class="srch"><input type="text" id="q" placeholder="Filter results..." onkeyup="filterTable()"></div>
<table id="tbl">
<thead><tr>
<th onclick="sortTable(0)" data-col="0">Status <span class="si"></span></th>
<th onclick="sortTable(1)" data-col="1">File Name <span class="si"></span></th>
<th onclick="sortTable(2)" data-col="2">Relative Path <span class="si"></span></th>
<th onclick="sortTable(3)" data-col="3" style="text-align:right">Source Size <span class="si"></span></th>
<th onclick="sortTable(4)" data-col="4" style="text-align:right">Dest Size <span class="si"></span></th>
</tr></thead>
<tbody id="tbody">$rows</tbody>
</table></div>
<div class="footer">SharePoint ToolBox &mdash; Transfer Verification</div>
<script>
var sortDir={};
function sortTable(c){
  var tb=document.getElementById('tbody'),rows=Array.from(tb.rows).filter(function(r){return !r.classList.contains('hidden');});
  var allRows=Array.from(tb.rows);
  var asc=sortDir[c]=!sortDir[c];
  rows.sort(function(a,b){
    if(c===3||c===4){
      return asc?(parseFloat(a.cells[c].dataset.size||0)-parseFloat(b.cells[c].dataset.size||0)):(parseFloat(b.cells[c].dataset.size||0)-parseFloat(a.cells[c].dataset.size||0));
    }
    var x=a.cells[c].innerText.trim(),y=b.cells[c].innerText.trim();
    return asc?x.localeCompare(y,'en',{numeric:true}):y.localeCompare(x,'en',{numeric:true});
  });
  var hidden=allRows.filter(function(r){return r.classList.contains('hidden');});
  rows.concat(hidden).forEach(function(r){tb.appendChild(r);});
  document.querySelectorAll('th[data-col] .si').forEach(function(s){s.textContent='';});
  var th=document.querySelector('th[data-col="'+c+'"] .si');
  if(th) th.textContent=asc?' \u25b2':' \u25bc';
}
function filterTable(){
  var q=document.getElementById('q').value.toLowerCase();
  Array.from(document.getElementById('tbody').rows).forEach(function(r){
    r.classList.toggle('hidden', q && !r.innerText.toLowerCase().includes(q));
  });
}
</script>
</body></html>
"@
    return $html
}

#endregion

#region ===== Bulk Site Creation =====

function Show-BulkSiteDialog {
    param(
        [System.Windows.Forms.Form]$Owner = $null,
        [hashtable]$Existing = $null   # pass to edit an existing entry
    )

    $templates = @(Load-Templates)

    $dlg = New-Object System.Windows.Forms.Form
    $dlg.Text          = if ($Existing) { T "bulk.dlg.title.edit" } else { T "bulk.dlg.title" }
    $dlg.Size          = New-Object System.Drawing.Size(520, 400)
    $dlg.StartPosition = "CenterParent"
    $dlg.FormBorderStyle = "FixedDialog"
    $dlg.MaximizeBox   = $false
    $dlg.MinimizeBox   = $false
    if ($Owner) { $dlg.Owner = $Owner }

    $y = 14

    # Site name
    $lblName = New-Object System.Windows.Forms.Label
    $lblName.Text     = T "bulk.lbl.name"
    $lblName.Location = New-Object System.Drawing.Point(14, $y)
    $lblName.Size     = New-Object System.Drawing.Size(480, 18)
    $y += 20
    $txtName = New-Object System.Windows.Forms.TextBox
    $txtName.Location = New-Object System.Drawing.Point(14, $y)
    $txtName.Size     = New-Object System.Drawing.Size(476, 22)
    $y += 30

    # URL alias
    $lblAlias = New-Object System.Windows.Forms.Label
    $lblAlias.Text     = T "bulk.lbl.alias"
    $lblAlias.Location = New-Object System.Drawing.Point(14, $y)
    $lblAlias.Size     = New-Object System.Drawing.Size(480, 18)
    $y += 20
    $txtAlias = New-Object System.Windows.Forms.TextBox
    $txtAlias.Location = New-Object System.Drawing.Point(14, $y)
    $txtAlias.Size     = New-Object System.Drawing.Size(476, 22)
    $y += 30

    # Auto-generate alias from name
    $txtName.Add_TextChanged({
        if (-not $txtAlias.Tag) {
            $raw = $txtName.Text.Trim().ToLower() -replace '[^a-z0-9\-]', '-' -replace '-+', '-' -replace '^-|-$', ''
            $txtAlias.Text = $raw
        }
    })
    $txtAlias.Add_TextChanged({ if ($txtAlias.Focused) { $txtAlias.Tag = "manual" } })

    # Site type
    $lblType = New-Object System.Windows.Forms.Label
    $lblType.Text     = T "bulk.lbl.type"
    $lblType.Location = New-Object System.Drawing.Point(14, $y)
    $lblType.Size     = New-Object System.Drawing.Size(150, 18)
    $radTeam = New-Object System.Windows.Forms.RadioButton
    $radTeam.Text     = T "bulk.rad.team"
    $radTeam.Location = New-Object System.Drawing.Point(170, ($y - 2))
    $radTeam.Size     = New-Object System.Drawing.Size(140, 22)
    $radTeam.Checked  = $true
    $radComm = New-Object System.Windows.Forms.RadioButton
    $radComm.Text     = T "bulk.rad.comm"
    $radComm.Location = New-Object System.Drawing.Point(320, ($y - 2))
    $radComm.Size     = New-Object System.Drawing.Size(170, 22)
    $y += 28

    # Template
    $lblTpl = New-Object System.Windows.Forms.Label
    $lblTpl.Text     = T "bulk.lbl.template"
    $lblTpl.Location = New-Object System.Drawing.Point(14, $y)
    $lblTpl.Size     = New-Object System.Drawing.Size(150, 18)
    $cboTpl = New-Object System.Windows.Forms.ComboBox
    $cboTpl.DropDownStyle = "DropDownList"
    $cboTpl.Location = New-Object System.Drawing.Point(170, ($y - 2))
    $cboTpl.Size     = New-Object System.Drawing.Size(320, 22)
    $cboTpl.Items.Add((T "bulk.none")) | Out-Null
    foreach ($t in $templates) { $cboTpl.Items.Add($t.name) | Out-Null }
    $cboTpl.SelectedIndex = 0
    $y += 30

    # Owners
    $lblOwners = New-Object System.Windows.Forms.Label
    $lblOwners.Text     = T "bulk.lbl.owners"
    $lblOwners.Location = New-Object System.Drawing.Point(14, $y)
    $lblOwners.Size     = New-Object System.Drawing.Size(480, 18)
    $y += 20
    $txtOwners = New-Object System.Windows.Forms.TextBox
    $txtOwners.Location        = New-Object System.Drawing.Point(14, $y)
    $txtOwners.Size            = New-Object System.Drawing.Size(476, 22)
    $txtOwners.PlaceholderText = T "bulk.ph.owners"
    $y += 30

    # Members
    $lblMembers = New-Object System.Windows.Forms.Label
    $lblMembers.Text     = T "bulk.lbl.members"
    $lblMembers.Location = New-Object System.Drawing.Point(14, $y)
    $lblMembers.Size     = New-Object System.Drawing.Size(380, 18)

    $btnCsvMembers = New-Object System.Windows.Forms.Button
    $btnCsvMembers.Text     = T "bulk.btn.csv.members"
    $btnCsvMembers.Location = New-Object System.Drawing.Point(394, ($y - 4))
    $btnCsvMembers.Size     = New-Object System.Drawing.Size(96, 24)
    $btnCsvMembers.Add_Click({
        $ofd = New-Object System.Windows.Forms.OpenFileDialog
        $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*"
        if ($ofd.ShowDialog($dlg) -eq "OK") {
            $rows = Import-Csv $ofd.FileName
            $emails = $rows | ForEach-Object {
                $r = $_
                $v = if ($r.Email) { $r.Email } elseif ($r.email) { $r.email }
                     elseif ($r.UPN) { $r.UPN } elseif ($r.upn) { $r.upn }
                     elseif ($r.UserPrincipalName) { $r.UserPrincipalName }
                     else { $r.userprincipalname }
                $v
            } | Where-Object { $_ } | Select-Object -Unique
            if ($emails.Count -gt 0) {
                $existing = $txtMembers.Text.Trim()
                if ($existing) { $txtMembers.Text = "$existing, $($emails -join ', ')" }
                else           { $txtMembers.Text = $emails -join ", " }
            }
        }
    })

    $y += 20
    $txtMembers = New-Object System.Windows.Forms.TextBox
    $txtMembers.Location        = New-Object System.Drawing.Point(14, $y)
    $txtMembers.Size            = New-Object System.Drawing.Size(476, 22)
    $txtMembers.PlaceholderText = T "bulk.ph.members"
    $y += 36

    # OK / Cancel
    $btnOk = New-Object System.Windows.Forms.Button
    $btnOk.Text     = "OK"
    $btnOk.Location = New-Object System.Drawing.Point(310, $y)
    $btnOk.Size     = New-Object System.Drawing.Size(85, 30)
    $btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK
    $dlg.AcceptButton   = $btnOk

    $btnCancel = New-Object System.Windows.Forms.Button
    $btnCancel.Text     = "Cancel"
    $btnCancel.Location = New-Object System.Drawing.Point(405, $y)
    $btnCancel.Size     = New-Object System.Drawing.Size(85, 30)
    $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
    $dlg.CancelButton   = $btnCancel

    # Pre-fill if editing
    if ($Existing) {
        $txtName.Text  = $Existing.Name
        $txtAlias.Tag  = "manual"
        $txtAlias.Text = $Existing.Alias
        if ($Existing.Type -eq "Communication") { $radComm.Checked = $true }
        if ($Existing.Template) {
            $idx = $cboTpl.Items.IndexOf($Existing.Template)
            if ($idx -ge 0) { $cboTpl.SelectedIndex = $idx }
        }
        $txtOwners.Text  = $Existing.Owners
        $txtMembers.Text = $Existing.Members
    }

    $dlg.Controls.AddRange(@($lblName, $txtName, $lblAlias, $txtAlias,
        $lblType, $radTeam, $radComm, $lblTpl, $cboTpl,
        $lblOwners, $txtOwners, $lblMembers, $btnCsvMembers, $txtMembers,
        $btnOk, $btnCancel))

    $result = $dlg.ShowDialog($Owner)
    if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
        $name  = $txtName.Text.Trim()
        $alias = $txtAlias.Text.Trim()
        if (-not $name -or -not $alias) { return $null }
        return @{
            Name     = $name
            Alias    = $alias
            Type     = if ($radTeam.Checked) { "Team" } else { "Communication" }
            Template = if ($cboTpl.SelectedIndex -gt 0) { $cboTpl.SelectedItem } else { "" }
            Owners   = $txtOwners.Text.Trim()
            Members  = $txtMembers.Text.Trim()
        }
    }
    return $null
}

#endregion

#region ===== Internationalization =====

$script:LangDefault = @{
    "profile"              = "Profile:"
    "tenant.url"           = "Tenant URL:"
    "client.id"            = "Client ID:"
    "site.url"             = "Site URL:"
    "output.folder"        = "Output Folder:"
    "btn.new"              = "New"
    "btn.save"             = "Save"
    "btn.rename"           = "Rename"
    "btn.delete"           = "Del."
    "btn.view.sites"       = "View Sites"
    "btn.browse"           = "Browse..."
    "tab.perms"            = "  Permissions  "
    "tab.storage"          = "  Storage  "
    "tab.templates"        = "  Templates  "
    "tab.search"           = "  File Search  "
    "tab.dupes"            = "  Duplicates  "
    "grp.scan.opts"        = "Scan Options"
    "chk.scan.folders"     = "Scan Folders"
    "chk.recursive"        = "Recursive (subsites)"
    "lbl.folder.depth"     = "Folder depth:"
    "chk.max.depth"        = "Maximum (all levels)"
    "chk.inherited.perms"  = "Include Inherited Permissions"
    "grp.export.fmt"       = "Export Format"
    "rad.csv.perms"        = "CSV"
    "rad.html.perms"       = "HTML"
    "btn.gen.perms"        = "Generate Report"
    "btn.open.perms"       = "Open Report"
    "chk.per.lib"          = "Per-Library Breakdown"
    "chk.subsites"         = "Include Subsites"
    "stor.note"            = "Note: deeper folder scans on large sites may take several minutes."
    "btn.gen.storage"      = "Generate Metrics"
    "btn.open.storage"     = "Open Report"
    "tpl.desc"             = "Create templates from an existing site and apply them to create new sites."
    "btn.manage.tpl"       = "Manage templates..."
    "tpl.count"            = "template(s) saved - click to manage"
    "grp.search.filters"   = "Search Filters"
    "lbl.extensions"       = "Extension(s):"
    "lbl.regex"            = "Name / Regex:"
    "chk.created.after"    = "Created after:"
    "chk.created.before"   = "Created before:"
    "chk.modified.after"   = "Modified after:"
    "chk.modified.before"  = "Modified before:"
    "lbl.created.by"       = "Created by:"
    "lbl.modified.by"      = "Modified by:"
    "lbl.library"          = "Library:"
    "grp.search.fmt"       = "Export Format"
    "lbl.max.results"      = "Max results:"
    "btn.run.search"       = "Run Search"
    "btn.open.search"      = "Open Results"
    "grp.dup.type"         = "Duplicate Type"
    "rad.dup.files"        = "Duplicate files"
    "rad.dup.folders"      = "Duplicate folders"
    "grp.dup.criteria"     = "Comparison Criteria"
    "lbl.dup.note"         = "Name is always the primary criterion. Check additional criteria:"
    "chk.dup.size"         = "Same size"
    "chk.dup.created"      = "Same creation date"
    "chk.dup.modified"     = "Same modification date"
    "chk.dup.subfolders"   = "Same subfolder count"
    "chk.dup.filecount"    = "Same file count"
    "grp.options"          = "Options"
    "chk.include.subsites" = "Include subsites"
    "btn.run.scan"         = "Run Scan"
    "btn.open.results"     = "Open Results"
    "lbl.log"              = "Log:"
    "menu.settings"        = "Settings"
    "menu.json.folder"     = "JSON Data Folder..."
    "menu.language"        = "Language"
    "dlg.json.folder.desc"      = "Select the storage folder for JSON files (profiles, templates)"
    "dlg.folder.not.found"      = "The folder '{0}' does not exist. Do you want to create it?"
    "dlg.folder.not.found.title"= "Folder not found"
    "msg.lang.applied"          = "Language applied: {0}"
    "msg.lang.applied.title"    = "Language"
    "ph.extensions"        = "docx pdf xlsx"
    "ph.regex"             = "Ex: report.* or \.bak$"
    "ph.created.by"        = "First Last or email"
    "ph.modified.by"       = "First Last or email"
    "ph.library"           = "Optional relative path e.g. Shared Documents"
    "ph.dup.lib"           = "All (leave empty)"
    "tab.transfer"         = "  Transfer  "
    "grp.xfer.source"      = "Source"
    "grp.xfer.dest"        = "Destination"
    "lbl.xfer.site"        = "Site URL:"
    "lbl.xfer.library"     = "Library / Folder:"
    "grp.xfer.options"     = "Options"
    "chk.xfer.recursive"   = "Include subfolders (recursive)"
    "chk.xfer.overwrite"   = "Overwrite existing files"
    "btn.xfer.start"       = "Start Transfer"
    "btn.xfer.verify"      = "Verify"
    "btn.xfer.open"        = "Open Report"
    "ph.xfer.site"         = "https://tenant.sharepoint.com/sites/xxx"
    "ph.xfer.library"      = "Shared Documents/subfolder"
    "xfer.note"            = "Only the current version of each file is transferred (no version history)."
    "chk.xfer.create.folders" = "Create missing folders"
    "btn.xfer.csv"         = "Import CSV..."
    "btn.xfer.csv.clear"   = "Clear"
    "lbl.xfer.csv.info"    = "{0} transfer(s) loaded"
    "lbl.xfer.report.fmt"  = "Report:"
    "tab.bulk"             = "  Bulk Create  "
    "grp.bulk.list"        = "Sites to create"
    "btn.bulk.add"         = "Add Site..."
    "btn.bulk.csv"         = "Import CSV..."
    "btn.bulk.remove"      = "Remove"
    "btn.bulk.clear"       = "Clear All"
    "btn.bulk.create"      = "Create All Sites"
    "bulk.col.name"        = "Site Name"
    "bulk.col.alias"       = "URL Alias"
    "bulk.col.type"        = "Type"
    "bulk.col.template"    = "Template"
    "bulk.col.owners"      = "Owners"
    "bulk.col.members"     = "Members"
    "bulk.dlg.title"       = "Add Site"
    "bulk.dlg.title.edit"  = "Edit Site"
    "bulk.lbl.name"        = "Site name:"
    "bulk.lbl.alias"       = "URL alias (after /sites/):"
    "bulk.lbl.type"        = "Site type:"
    "bulk.rad.team"        = "Team Site"
    "bulk.rad.comm"        = "Communication Site"
    "bulk.lbl.template"    = "Template:"
    "bulk.lbl.owners"      = "Owners (comma-separated):"
    "bulk.lbl.members"     = "Members (comma-separated):"
    "bulk.btn.csv.members" = "Import CSV..."
    "bulk.none"            = "(None)"
    "bulk.ph.owners"       = "admin@domain.com, user2@domain.com"
    "bulk.ph.members"      = "user@domain.com, ..."
    "bulk.status.pending"  = "Pending"
    "bulk.status.creating" = "Creating..."
    "bulk.status.ok"       = "OK"
    "bulk.status.error"    = "Error"
    "tab.structure"        = "  Structure  "
    "grp.struct.csv"       = "CSV Import"
    "lbl.struct.desc"      = "Import a CSV to create a folder tree. Each column represents a depth level."
    "btn.struct.csv"       = "Load CSV..."
    "grp.struct.preview"   = "Preview"
    "grp.struct.target"    = "Target"
    "lbl.struct.library"   = "Target library:"
    "ph.struct.library"    = "Shared Documents"
    "btn.struct.create"    = "Create Structure"
    "btn.struct.clear"     = "Clear"
    "struct.col.path"      = "Full Path"
    "struct.col.depth"     = "Depth"
    "tab.versions"              = "  Versions  "
    "grp.ver.keep"              = "Versions to Keep"
    "lbl.ver.count"             = "Number of versions to keep:"
    "chk.ver.date"              = "Also filter by date"
    "rad.ver.before"            = "Keep versions before:"
    "rad.ver.after"             = "Keep versions after:"
    "grp.ver.scope"             = "Scope"
    "lbl.ver.library"           = "Library / Folder:"
    "ph.ver.library"            = "Shared Documents"
    "chk.ver.recursive"         = "Include subfolders (recursive)"
    "chk.ver.subsites"          = "Include subsites"
    "chk.ver.dryrun"            = "Dry run (preview only, no deletion)"
    "btn.ver.run"               = "Clean Versions"
    "btn.ver.open"              = "Open Report"
    "btn.register.app"          = "Register"
    "reg.title"                 = "App Registration"
    "reg.offer"                 = "No Client ID provided. Register a new app on this tenant?"
    "reg.confirm"               = "Register 'SharePoint Toolbox' app on tenant {0}?"
    "reg.in.progress"           = "Registering..."
    "reg.success"               = "App registered successfully!`nClient ID: {0}`nYou can save this profile to reuse it."
    "reg.err.tenant"            = "Cannot determine tenant from the provided URL."
    "reg.err.nocmd"             = "PnP.PowerShell module does not support app registration. Please register the app manually in Entra ID."
    "reg.err.no.id"             = "Registration completed but no Client ID was returned."
    "reg.err.failed"            = "Registration failed:`n{0}"
    "reg.err.no.tenant"         = "Please enter a Tenant URL first."
    "reg.err.nopwsh"            = "PowerShell 7+ (pwsh) is required for app registration but was not found. Install it from https://aka.ms/powershell"
    "validate.missing.clientid" = "Please enter a Client ID."
    "validate.missing.clientid.hint" = "Please enter a Client ID or use the 'Register' button to create one."
    "validate.missing.title"    = "Missing Field"
}

$script:Lang = $null   # null = use LangDefault

function T([string]$key) {
    if ($script:Lang -and $script:Lang.$key) { return $script:Lang.$key }
    if ($script:LangDefault.ContainsKey($key)) { return $script:LangDefault[$key] }
    return $key
}

function Get-LangDir {
    $base = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
    return Join-Path $base "lang"
}

function Get-LangFiles {
    $dir = Get-LangDir
    if (-not (Test-Path $dir)) { return @() }
    return @(Get-ChildItem -Path $dir -Filter "*.json" | ForEach-Object {
        $code = $_.BaseName
        $name = $code
        try {
            $data = Get-Content $_.FullName -Raw | ConvertFrom-Json
            if ($data.'_name') { $name = $data.'_name' }
        } catch {}
        [PSCustomObject]@{ Code = $code; Name = $name; Path = $_.FullName }
    })
}

function Load-Language([string]$LangCode) {
    if ([string]::IsNullOrWhiteSpace($LangCode) -or $LangCode -eq "en") {
        $script:Lang = $null
        $script:CurrentLang = "en"
        return
    }
    $dir  = Get-LangDir
    $path = Join-Path $dir "$LangCode.json"
    if (-not (Test-Path $path)) { return }
    try {
        $data = Get-Content $path -Raw | ConvertFrom-Json
        $ht   = @{}
        $data.PSObject.Properties | ForEach-Object { $ht[$_.Name] = $_.Value }
        $script:Lang        = $ht
        $script:CurrentLang = $LangCode
    } catch {}
}

function Update-UILanguage {
    # Main labels
    if ($script:i18nMap) {
        foreach ($kv in $script:i18nMap.GetEnumerator()) {
            $ctrl = $kv.Value.Control
            $key  = $kv.Value.Key
            if ($ctrl -and !$ctrl.IsDisposed) { $ctrl.Text = T $key }
        }
    }
    # Tab pages (Text property)
    if ($script:i18nTabs) {
        foreach ($kv in $script:i18nTabs.GetEnumerator()) {
            $tab = $kv.Value.Control
            $key = $kv.Value.Key
            if ($tab -and !$tab.IsDisposed) { $tab.Text = T $key }
        }
    }
    # Menu items
    if ($script:i18nMenus) {
        foreach ($kv in $script:i18nMenus.GetEnumerator()) {
            $mi  = $kv.Value.Control
            $key = $kv.Value.Key
            if ($mi) { $mi.Text = T $key }
        }
    }
    # Placeholder texts
    if ($script:i18nPlaceholders) {
        foreach ($kv in $script:i18nPlaceholders.GetEnumerator()) {
            $ctrl = $kv.Value.Control
            $key  = $kv.Value.Key
            if ($ctrl -and !$ctrl.IsDisposed) { $ctrl.PlaceholderText = T $key }
        }
    }
}

$script:CurrentLang = "en"

#endregion

#region ===== GUI =====

$form                  = New-Object System.Windows.Forms.Form
$form.Text             = "SharePoint Toolbox"
$form.Size             = New-Object System.Drawing.Size(700, 840)
$form.StartPosition    = "CenterScreen"
$form.FormBorderStyle  = "FixedDialog"
$form.MaximizeBox      = $false
$form.BackColor        = [System.Drawing.Color]::WhiteSmoke

# ── MenuStrip ─────────────────────────────────────────────────────────────────
$menuStrip              = New-Object System.Windows.Forms.MenuStrip
$menuStrip.BackColor    = [System.Drawing.Color]::WhiteSmoke
$menuStrip.RenderMode   = [System.Windows.Forms.ToolStripRenderMode]::System

$menuSettings           = New-Object System.Windows.Forms.ToolStripMenuItem
$menuSettings.Text      = T "menu.settings"
$menuJsonFolder         = New-Object System.Windows.Forms.ToolStripMenuItem
$menuJsonFolder.Text    = T "menu.json.folder"
[void]$menuSettings.DropDownItems.Add($menuJsonFolder)

$menuLang               = New-Object System.Windows.Forms.ToolStripMenuItem
$menuLang.Text          = T "menu.language"
$menuLangEn             = New-Object System.Windows.Forms.ToolStripMenuItem
$menuLangEn.Text        = "English (US)"
$menuLangEn.Tag         = "en"
$menuLangEn.Checked     = ($script:CurrentLang -eq "en")
[void]$menuLang.DropDownItems.Add($menuLangEn)
[void]$menuLang.DropDownItems.Add([System.Windows.Forms.ToolStripSeparator]::new())
foreach ($lf in (Get-LangFiles)) {
    $mi         = New-Object System.Windows.Forms.ToolStripMenuItem
    $mi.Text    = $lf.Name
    $mi.Tag     = $lf.Code
    $mi.Checked = ($script:CurrentLang -eq $lf.Code)
    [void]$menuLang.DropDownItems.Add($mi)
}
[void]$menuStrip.Items.Add($menuSettings)
[void]$menuStrip.Items.Add($menuLang)
$form.MainMenuStrip = $menuStrip

# ── Label helper (positions offset +24 to account for MenuStrip) ──────────────
$lbl = { param($t,$x,$y)
    $l = New-Object System.Windows.Forms.Label
    $l.Text = $t; $l.Location = New-Object System.Drawing.Point($x,$y)
    $l.Size = New-Object System.Drawing.Size(115,22); $l.TextAlign = "MiddleLeft"; $l
}

# ── Profile selector ──────────────────────────────────────────────────────────
$lblProfile                  = (& $lbl (T "profile")       20 46)
$cboProfile                  = New-Object System.Windows.Forms.ComboBox
$cboProfile.Location         = New-Object System.Drawing.Point(140, 44)
$cboProfile.Size             = New-Object System.Drawing.Size(248, 24)
$cboProfile.DropDownStyle    = "DropDownList"
$cboProfile.Font             = New-Object System.Drawing.Font("Segoe UI", 9)

$btnProfileNew               = New-Object System.Windows.Forms.Button
$btnProfileNew.Text          = T "btn.new"
$btnProfileNew.Location      = New-Object System.Drawing.Point(396, 43)
$btnProfileNew.Size          = New-Object System.Drawing.Size(60, 26)

$btnProfileSave              = New-Object System.Windows.Forms.Button
$btnProfileSave.Text         = T "btn.save"
$btnProfileSave.Location     = New-Object System.Drawing.Point(460, 43)
$btnProfileSave.Size         = New-Object System.Drawing.Size(60, 26)

$btnProfileRename            = New-Object System.Windows.Forms.Button
$btnProfileRename.Text       = T "btn.rename"
$btnProfileRename.Location   = New-Object System.Drawing.Point(524, 43)
$btnProfileRename.Size       = New-Object System.Drawing.Size(72, 26)

$btnProfileDelete            = New-Object System.Windows.Forms.Button
$btnProfileDelete.Text       = T "btn.delete"
$btnProfileDelete.Location   = New-Object System.Drawing.Point(600, 43)
$btnProfileDelete.Size       = New-Object System.Drawing.Size(62, 26)

$lblTenantUrl          = (& $lbl (T "tenant.url")  20 76)
$txtTenantUrl          = New-Object System.Windows.Forms.TextBox
$txtTenantUrl.Location = New-Object System.Drawing.Point(140, 76)
$txtTenantUrl.Size     = New-Object System.Drawing.Size(400, 22)
$txtTenantUrl.Font     = New-Object System.Drawing.Font("Consolas", 9)

$btnBrowseSites             = New-Object System.Windows.Forms.Button
$btnBrowseSites.Text        = T "btn.view.sites"
$btnBrowseSites.Location    = New-Object System.Drawing.Point(548, 74)
$btnBrowseSites.Size        = New-Object System.Drawing.Size(92, 26)

$lblClientId           = (& $lbl (T "client.id")   20 108)
$txtClientId           = New-Object System.Windows.Forms.TextBox
$txtClientId.Location  = New-Object System.Drawing.Point(140, 108)
$txtClientId.Size      = New-Object System.Drawing.Size(400, 22)
$txtClientId.Font      = New-Object System.Drawing.Font("Consolas", 9)

$btnRegisterApp             = New-Object System.Windows.Forms.Button
$btnRegisterApp.Text        = T "btn.register.app"
$btnRegisterApp.Location    = New-Object System.Drawing.Point(548, 106)
$btnRegisterApp.Size        = New-Object System.Drawing.Size(92, 26)

$txtClientId.Add_TextChanged({
    $btnRegisterApp.Enabled = [string]::IsNullOrWhiteSpace($txtClientId.Text)
})

$lblSiteURL            = (& $lbl (T "site.url")    20 140)
$txtSiteURL            = New-Object System.Windows.Forms.TextBox
$txtSiteURL.Location   = New-Object System.Drawing.Point(140, 140)
$txtSiteURL.Size       = New-Object System.Drawing.Size(500, 22)

$lblOutput             = (& $lbl (T "output.folder") 20 172)
$txtOutput             = New-Object System.Windows.Forms.TextBox
$txtOutput.Location    = New-Object System.Drawing.Point(140, 172)
$txtOutput.Size        = New-Object System.Drawing.Size(408, 22)
$txtOutput.Text        = $PWD.Path

$btnBrowse             = New-Object System.Windows.Forms.Button
$btnBrowse.Text        = T "btn.browse"
$btnBrowse.Location    = New-Object System.Drawing.Point(558, 170)
$btnBrowse.Size        = New-Object System.Drawing.Size(82, 26)

$sep                   = New-Object System.Windows.Forms.Panel
$sep.Location          = New-Object System.Drawing.Point(20, 206)
$sep.Size              = New-Object System.Drawing.Size(642, 1)
$sep.BackColor         = [System.Drawing.Color]::LightGray

# ── TabControl ─────────────────────────────────────────────────────────────────
$tabs                  = New-Object System.Windows.Forms.TabControl
$tabs.Location         = New-Object System.Drawing.Point(10, 214)
$tabs.Size             = New-Object System.Drawing.Size(662, 310)
$tabs.Font             = New-Object System.Drawing.Font("Segoe UI", 9)

# helper: GroupBox
function New-Group($text, $x, $y, $w, $h) {
    $g = New-Object System.Windows.Forms.GroupBox
    $g.Text = $text; $g.Location = New-Object System.Drawing.Point($x,$y)
    $g.Size = New-Object System.Drawing.Size($w,$h); $g
}
# helper: CheckBox
function New-Check($text, $x, $y, $w, $checked=$false) {
    $c = New-Object System.Windows.Forms.CheckBox
    $c.Text = $text; $c.Location = New-Object System.Drawing.Point($x,$y)
    $c.Size = New-Object System.Drawing.Size($w,22); $c.Checked = $checked; $c
}
# helper: RadioButton
function New-Radio($text, $x, $y, $w, $checked=$false) {
    $r = New-Object System.Windows.Forms.RadioButton
    $r.Text = $text; $r.Location = New-Object System.Drawing.Point($x,$y)
    $r.Size = New-Object System.Drawing.Size($w,22); $r.Checked = $checked; $r
}
# helper: Action button
function New-ActionBtn($text, $x, $y, $color) {
    $b = New-Object System.Windows.Forms.Button
    $b.Text = $text; $b.Location = New-Object System.Drawing.Point($x,$y)
    $b.Size = New-Object System.Drawing.Size(155,34); $b.BackColor = $color
    $b.ForeColor = [System.Drawing.Color]::White; $b.FlatStyle = "Flat"
    $b.Font = New-Object System.Drawing.Font("Segoe UI",9,[System.Drawing.FontStyle]::Bold); $b
}

# ══ Tab 1: Permissions ════════════════════════════════════════════════════════
$tabPerms              = New-Object System.Windows.Forms.TabPage
$tabPerms.Text         = T "tab.perms"
$tabPerms.BackColor    = [System.Drawing.Color]::WhiteSmoke

$grpPermOpts           = New-Group (T "grp.scan.opts") 10 10 615 96
$chkScanFolders        = New-Check (T "chk.scan.folders")     15 24 150 $true
$chkRecursive          = New-Check (T "chk.recursive")       175 24 185

# Folder depth controls (only active when Scan Folders is checked)
$lblPermDepth          = New-Object System.Windows.Forms.Label
$lblPermDepth.Text     = T "lbl.folder.depth"
$lblPermDepth.Location = New-Object System.Drawing.Point(15, 50)
$lblPermDepth.Size     = New-Object System.Drawing.Size(100, 22)
$lblPermDepth.TextAlign = "MiddleLeft"

$nudPermDepth          = New-Object System.Windows.Forms.NumericUpDown
$nudPermDepth.Location = New-Object System.Drawing.Point(118, 50)
$nudPermDepth.Size     = New-Object System.Drawing.Size(52, 22)
$nudPermDepth.Minimum  = 1
$nudPermDepth.Maximum  = 20
$nudPermDepth.Value    = 1

$chkPermMaxDepth       = New-Object System.Windows.Forms.CheckBox
$chkPermMaxDepth.Text  = T "chk.max.depth"
$chkPermMaxDepth.Location = New-Object System.Drawing.Point(182, 52)
$chkPermMaxDepth.Size  = New-Object System.Drawing.Size(180, 20)

$chkInheritedPerms     = New-Check (T "chk.inherited.perms")  15 74 230
$grpPermOpts.Controls.AddRange(@($chkScanFolders, $chkRecursive, $lblPermDepth, $nudPermDepth, $chkPermMaxDepth, $chkInheritedPerms))

# Disable depth controls when Scan Folders is unchecked
$chkScanFolders.Add_CheckedChanged({
    $on = $chkScanFolders.Checked
    $lblPermDepth.Enabled    = $on
    $nudPermDepth.Enabled    = $on -and -not $chkPermMaxDepth.Checked
    $chkPermMaxDepth.Enabled = $on
})
# When Maximum is checked, grey out the spinner
$chkPermMaxDepth.Add_CheckedChanged({
    $nudPermDepth.Enabled = $chkScanFolders.Checked -and -not $chkPermMaxDepth.Checked
})

$grpPermFmt            = New-Group (T "grp.export.fmt") 10 114 615 58
$radPermCSV            = New-Radio (T "rad.csv.perms")   15 24 280 $true
$radPermHTML           = New-Radio (T "rad.html.perms") 305 24 290
$grpPermFmt.Controls.AddRange(@($radPermCSV, $radPermHTML))

$btnGenPerms           = New-ActionBtn (T "btn.gen.perms")  10 184 ([System.Drawing.Color]::SteelBlue)
$btnOpenPerms          = New-Object System.Windows.Forms.Button
$btnOpenPerms.Text     = T "btn.open.perms"
$btnOpenPerms.Location = New-Object System.Drawing.Point(175, 184)
$btnOpenPerms.Size     = New-Object System.Drawing.Size(120, 34)
$btnOpenPerms.Enabled  = $false

$tabPerms.Controls.AddRange(@($grpPermOpts, $grpPermFmt, $btnGenPerms, $btnOpenPerms))

# ══ Tab 2: Storage Metrics ════════════════════════════════════════════════════
$tabStorage            = New-Object System.Windows.Forms.TabPage
$tabStorage.Text       = T "tab.storage"
$tabStorage.BackColor  = [System.Drawing.Color]::WhiteSmoke

$grpStorOpts           = New-Group (T "grp.scan.opts") 10 10 615 108
$chkStorPerLib         = New-Check (T "chk.per.lib")    15 24 200 $true
$chkStorSubsites       = New-Check (T "chk.subsites")  230 24 170

# Folder depth controls (only relevant in per-library mode)
$lblDepth              = New-Object System.Windows.Forms.Label
$lblDepth.Text         = T "lbl.folder.depth"
$lblDepth.Location     = New-Object System.Drawing.Point(15, 52)
$lblDepth.Size         = New-Object System.Drawing.Size(100, 22)
$lblDepth.TextAlign    = "MiddleLeft"

$nudDepth              = New-Object System.Windows.Forms.NumericUpDown
$nudDepth.Location     = New-Object System.Drawing.Point(118, 52)
$nudDepth.Size         = New-Object System.Drawing.Size(52, 22)
$nudDepth.Minimum      = 1
$nudDepth.Maximum      = 20
$nudDepth.Value        = 1

$chkMaxDepth           = New-Object System.Windows.Forms.CheckBox
$chkMaxDepth.Text      = T "chk.max.depth"
$chkMaxDepth.Location  = New-Object System.Drawing.Point(182, 54)
$chkMaxDepth.Size      = New-Object System.Drawing.Size(180, 20)

$lblStorNote           = New-Object System.Windows.Forms.Label
$lblStorNote.Text      = T "stor.note"
$lblStorNote.Location  = New-Object System.Drawing.Point(15, 80)
$lblStorNote.Size      = New-Object System.Drawing.Size(580, 18)
$lblStorNote.ForeColor = [System.Drawing.Color]::Gray
$lblStorNote.Font      = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)

$grpStorOpts.Controls.AddRange(@($chkStorPerLib, $chkStorSubsites, $lblDepth, $nudDepth, $chkMaxDepth, $lblStorNote))

$grpStorFmt            = New-Group (T "grp.export.fmt") 10 128 615 58
$radStorCSV            = New-Radio (T "rad.csv.perms")   15 24 280 $true
$radStorHTML           = New-Radio (T "rad.html.perms") 305 24 290
$grpStorFmt.Controls.AddRange(@($radStorCSV, $radStorHTML))

$msGreen               = [System.Drawing.Color]::FromArgb(16,124,16)
$btnGenStorage         = New-ActionBtn (T "btn.gen.storage")   10 200 $msGreen
$btnOpenStorage        = New-Object System.Windows.Forms.Button
$btnOpenStorage.Text   = T "btn.open.storage"
$btnOpenStorage.Location = New-Object System.Drawing.Point(175, 200)
$btnOpenStorage.Size   = New-Object System.Drawing.Size(120, 34)
$btnOpenStorage.Enabled = $false

# Disable depth controls when Per-Library is unchecked
$chkStorPerLib.Add_CheckedChanged({
    $on = $chkStorPerLib.Checked
    $lblDepth.Enabled    = $on
    $nudDepth.Enabled    = $on -and -not $chkMaxDepth.Checked
    $chkMaxDepth.Enabled = $on
})
# When Maximum is checked, grey out the spinner
$chkMaxDepth.Add_CheckedChanged({
    $nudDepth.Enabled = $chkStorPerLib.Checked -and -not $chkMaxDepth.Checked
})

$tabStorage.Controls.AddRange(@($grpStorOpts, $grpStorFmt, $btnGenStorage, $btnOpenStorage))

# ══ Tab 3: Templates ══════════════════════════════════════════════════════
$tabTemplates              = New-Object System.Windows.Forms.TabPage
$tabTemplates.Text         = T "tab.templates"
$tabTemplates.BackColor    = [System.Drawing.Color]::WhiteSmoke

$lblTplDesc                = New-Object System.Windows.Forms.Label
$lblTplDesc.Text           = T "tpl.desc"
$lblTplDesc.Location       = New-Object System.Drawing.Point(10, 18)
$lblTplDesc.Size           = New-Object System.Drawing.Size(580, 20)
$lblTplDesc.ForeColor      = [System.Drawing.Color]::DimGray

$lblTplCount               = New-Object System.Windows.Forms.Label
$lblTplCount.Name          = "lblTplCount"
$lblTplCount.Location      = New-Object System.Drawing.Point(10, 44)
$lblTplCount.Size          = New-Object System.Drawing.Size(380, 20)
$lblTplCount.ForeColor     = [System.Drawing.Color]::DimGray
$lblTplCount.Font          = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)

$btnOpenTplMgr             = New-Object System.Windows.Forms.Button
$btnOpenTplMgr.Text        = T "btn.manage.tpl"
$btnOpenTplMgr.Location    = New-Object System.Drawing.Point(10, 72)
$btnOpenTplMgr.Size        = New-Object System.Drawing.Size(185, 34)
$btnOpenTplMgr.BackColor   = [System.Drawing.Color]::FromArgb(50, 50, 120)
$btnOpenTplMgr.ForeColor   = [System.Drawing.Color]::White
$btnOpenTplMgr.FlatStyle   = "Flat"
$btnOpenTplMgr.Font        = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)

$tabTemplates.Controls.AddRange(@($lblTplDesc, $lblTplCount, $btnOpenTplMgr))

# ══ Tab 4: Recherche de fichiers ══════════════════════════════════════════════
$tabSearch             = New-Object System.Windows.Forms.TabPage
$tabSearch.Text        = T "tab.search"
$tabSearch.BackColor   = [System.Drawing.Color]::WhiteSmoke

# ── GroupBox Filtres ───────────────────────────────────────────────────────────
$grpSearchFilters      = New-Group (T "grp.search.filters") 10 6 620 170

# Row 1 - Extension & Regex
$lblSrchExt            = New-Object System.Windows.Forms.Label
$lblSrchExt.Text       = T "lbl.extensions"
$lblSrchExt.Location   = New-Object System.Drawing.Point(10, 24)
$lblSrchExt.Size       = New-Object System.Drawing.Size(88, 22)
$lblSrchExt.TextAlign  = "MiddleLeft"
$txtSrchExt            = New-Object System.Windows.Forms.TextBox
$txtSrchExt.Location   = New-Object System.Drawing.Point(100, 24)
$txtSrchExt.Size       = New-Object System.Drawing.Size(120, 22)
$txtSrchExt.Font       = New-Object System.Drawing.Font("Consolas", 9)
$txtSrchExt.PlaceholderText = T "ph.extensions"

$lblSrchRegex          = New-Object System.Windows.Forms.Label
$lblSrchRegex.Text     = T "lbl.regex"
$lblSrchRegex.Location = New-Object System.Drawing.Point(232, 24)
$lblSrchRegex.Size     = New-Object System.Drawing.Size(88, 22)
$lblSrchRegex.TextAlign = "MiddleLeft"
$txtSrchRegex          = New-Object System.Windows.Forms.TextBox
$txtSrchRegex.Location = New-Object System.Drawing.Point(322, 24)
$txtSrchRegex.Size     = New-Object System.Drawing.Size(286, 22)
$txtSrchRegex.Font     = New-Object System.Drawing.Font("Consolas", 9)
$txtSrchRegex.PlaceholderText = T "ph.regex"

# Row 2 - Created dates
$chkSrchCrA            = New-Object System.Windows.Forms.CheckBox
$chkSrchCrA.Text       = T "chk.created.after"
$chkSrchCrA.Location   = New-Object System.Drawing.Point(10, 52)
$chkSrchCrA.Size       = New-Object System.Drawing.Size(108, 22)
$dtpSrchCrA            = New-Object System.Windows.Forms.DateTimePicker
$dtpSrchCrA.Location   = New-Object System.Drawing.Point(120, 52)
$dtpSrchCrA.Size       = New-Object System.Drawing.Size(130, 22)
$dtpSrchCrA.Format     = [System.Windows.Forms.DateTimePickerFormat]::Short
$dtpSrchCrA.Enabled    = $false

$chkSrchCrB            = New-Object System.Windows.Forms.CheckBox
$chkSrchCrB.Text       = T "chk.created.before"
$chkSrchCrB.Location   = New-Object System.Drawing.Point(262, 52)
$chkSrchCrB.Size       = New-Object System.Drawing.Size(108, 22)
$dtpSrchCrB            = New-Object System.Windows.Forms.DateTimePicker
$dtpSrchCrB.Location   = New-Object System.Drawing.Point(372, 52)
$dtpSrchCrB.Size       = New-Object System.Drawing.Size(130, 22)
$dtpSrchCrB.Format     = [System.Windows.Forms.DateTimePickerFormat]::Short
$dtpSrchCrB.Enabled    = $false

$chkSrchCrA.Add_CheckedChanged({ $dtpSrchCrA.Enabled = $chkSrchCrA.Checked })
$chkSrchCrB.Add_CheckedChanged({ $dtpSrchCrB.Enabled = $chkSrchCrB.Checked })

# Row 3 - Modified dates
$chkSrchModA           = New-Object System.Windows.Forms.CheckBox
$chkSrchModA.Text      = T "chk.modified.after"
$chkSrchModA.Location  = New-Object System.Drawing.Point(10, 80)
$chkSrchModA.Size      = New-Object System.Drawing.Size(108, 22)
$dtpSrchModA           = New-Object System.Windows.Forms.DateTimePicker
$dtpSrchModA.Location  = New-Object System.Drawing.Point(120, 80)
$dtpSrchModA.Size      = New-Object System.Drawing.Size(130, 22)
$dtpSrchModA.Format    = [System.Windows.Forms.DateTimePickerFormat]::Short
$dtpSrchModA.Enabled   = $false

$chkSrchModB           = New-Object System.Windows.Forms.CheckBox
$chkSrchModB.Text      = T "chk.modified.before"
$chkSrchModB.Location  = New-Object System.Drawing.Point(262, 80)
$chkSrchModB.Size      = New-Object System.Drawing.Size(108, 22)
$dtpSrchModB           = New-Object System.Windows.Forms.DateTimePicker
$dtpSrchModB.Location  = New-Object System.Drawing.Point(372, 80)
$dtpSrchModB.Size      = New-Object System.Drawing.Size(130, 22)
$dtpSrchModB.Format    = [System.Windows.Forms.DateTimePickerFormat]::Short
$dtpSrchModB.Enabled   = $false

$chkSrchModA.Add_CheckedChanged({ $dtpSrchModA.Enabled = $chkSrchModA.Checked })
$chkSrchModB.Add_CheckedChanged({ $dtpSrchModB.Enabled = $chkSrchModB.Checked })

# Row 4 - Created by / Modified by
$lblSrchCrBy           = New-Object System.Windows.Forms.Label
$lblSrchCrBy.Text      = T "lbl.created.by"
$lblSrchCrBy.Location  = New-Object System.Drawing.Point(10, 108)
$lblSrchCrBy.Size      = New-Object System.Drawing.Size(70, 22)
$lblSrchCrBy.TextAlign = "MiddleLeft"
$txtSrchCrBy           = New-Object System.Windows.Forms.TextBox
$txtSrchCrBy.Location  = New-Object System.Drawing.Point(82, 108)
$txtSrchCrBy.Size      = New-Object System.Drawing.Size(168, 22)
$txtSrchCrBy.PlaceholderText = T "ph.created.by"

$lblSrchModBy          = New-Object System.Windows.Forms.Label
$lblSrchModBy.Text     = T "lbl.modified.by"
$lblSrchModBy.Location = New-Object System.Drawing.Point(262, 108)
$lblSrchModBy.Size     = New-Object System.Drawing.Size(82, 22)
$lblSrchModBy.TextAlign = "MiddleLeft"
$txtSrchModBy          = New-Object System.Windows.Forms.TextBox
$txtSrchModBy.Location = New-Object System.Drawing.Point(346, 108)
$txtSrchModBy.Size     = New-Object System.Drawing.Size(168, 22)
$txtSrchModBy.PlaceholderText = T "ph.modified.by"

# Row 5 - Library filter
$lblSrchLib            = New-Object System.Windows.Forms.Label
$lblSrchLib.Text       = T "lbl.library"
$lblSrchLib.Location   = New-Object System.Drawing.Point(10, 136)
$lblSrchLib.Size       = New-Object System.Drawing.Size(88, 22)
$lblSrchLib.TextAlign  = "MiddleLeft"
$txtSrchLib            = New-Object System.Windows.Forms.TextBox
$txtSrchLib.Location   = New-Object System.Drawing.Point(100, 136)
$txtSrchLib.Size       = New-Object System.Drawing.Size(508, 22)
$txtSrchLib.PlaceholderText = T "ph.library"

$grpSearchFilters.Controls.AddRange(@(
    $lblSrchExt, $txtSrchExt, $lblSrchRegex, $txtSrchRegex,
    $chkSrchCrA, $dtpSrchCrA, $chkSrchCrB, $dtpSrchCrB,
    $chkSrchModA, $dtpSrchModA, $chkSrchModB, $dtpSrchModB,
    $lblSrchCrBy, $txtSrchCrBy, $lblSrchModBy, $txtSrchModBy,
    $lblSrchLib, $txtSrchLib
))

# ── GroupBox Format ────────────────────────────────────────────────────────────
$grpSearchFmt          = New-Group (T "grp.search.fmt") 10 180 620 48
$radSrchCSV            = New-Radio (T "rad.csv.perms")  15 22 130 $true
$radSrchHTML           = New-Radio (T "rad.html.perms") 160 22 180
$lblSrchMax            = New-Object System.Windows.Forms.Label
$lblSrchMax.Text       = T "lbl.max.results"
$lblSrchMax.Location   = New-Object System.Drawing.Point(360, 22)
$lblSrchMax.Size       = New-Object System.Drawing.Size(96, 22)
$lblSrchMax.TextAlign  = "MiddleLeft"
$nudSrchMax            = New-Object System.Windows.Forms.NumericUpDown
$nudSrchMax.Location   = New-Object System.Drawing.Point(458, 22)
$nudSrchMax.Size       = New-Object System.Drawing.Size(70, 22)
$nudSrchMax.Minimum    = 10
$nudSrchMax.Maximum    = 50000
$nudSrchMax.Value      = 500
$nudSrchMax.Increment  = 100
$grpSearchFmt.Controls.AddRange(@($radSrchCSV, $radSrchHTML, $lblSrchMax, $nudSrchMax))

# ── Buttons ────────────────────────────────────────────────────────────────────
$btnSearch             = New-ActionBtn (T "btn.run.search") 10 232 ([System.Drawing.Color]::FromArgb(0, 120, 212))
$btnOpenSearch         = New-Object System.Windows.Forms.Button
$btnOpenSearch.Text    = T "btn.open.search"
$btnOpenSearch.Location = New-Object System.Drawing.Point(175, 232)
$btnOpenSearch.Size    = New-Object System.Drawing.Size(130, 34)
$btnOpenSearch.Enabled = $false

$tabSearch.Controls.AddRange(@($grpSearchFilters, $grpSearchFmt, $btnSearch, $btnOpenSearch))

# ══ Tab 5: Doublons ═══════════════════════════════════════════════════════════
$tabDupes              = New-Object System.Windows.Forms.TabPage
$tabDupes.Text         = T "tab.dupes"
$tabDupes.BackColor    = [System.Drawing.Color]::WhiteSmoke

# ── GroupBox: Type de doublons  (y=4, h=44 → bottom 48) ──────────────────────
$grpDupType            = New-Group (T "grp.dup.type") 10 4 638 44
$radDupFiles           = New-Radio (T "rad.dup.files")    10 16 190 $true
$radDupFolders         = New-Radio (T "rad.dup.folders") 210 16 190
$grpDupType.Controls.AddRange(@($radDupFiles, $radDupFolders))

# ── GroupBox: Critères de comparaison  (y=52, h=88 → bottom 140) ─────────────
$grpDupCrit            = New-Group (T "grp.dup.criteria") 10 52 638 88

$lblDupNote            = New-Object System.Windows.Forms.Label
$lblDupNote.Text       = T "lbl.dup.note"
$lblDupNote.Location   = New-Object System.Drawing.Point(10, 15)
$lblDupNote.Size       = New-Object System.Drawing.Size(610, 16)
$lblDupNote.Font       = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
$lblDupNote.ForeColor  = [System.Drawing.Color]::DimGray

# Row 1 - criteres communs
$chkDupSize            = New-Check (T "chk.dup.size")        10 34 148 $true
$chkDupCreated         = New-Check (T "chk.dup.created")    164 34 208
$chkDupModified        = New-Check (T "chk.dup.modified")   378 34 226

# Row 2 - criteres dossiers uniquement
$chkDupSubCount        = New-Check (T "chk.dup.subfolders")  10 60 210
$chkDupFileCount       = New-Check (T "chk.dup.filecount")  226 60 200
$chkDupSubCount.Enabled  = $false
$chkDupFileCount.Enabled = $false

$grpDupCrit.Controls.AddRange(@($lblDupNote,
    $chkDupSize, $chkDupCreated, $chkDupModified,
    $chkDupSubCount, $chkDupFileCount))

# Toggle folder-only criteria based on radio selection
$radDupFiles.Add_CheckedChanged({
    $chkDupSubCount.Enabled  = -not $radDupFiles.Checked
    $chkDupFileCount.Enabled = -not $radDupFiles.Checked
    if ($radDupFiles.Checked) { $chkDupSubCount.Checked = $false; $chkDupFileCount.Checked = $false }
})
$radDupFolders.Add_CheckedChanged({
    $chkDupSubCount.Enabled  = $radDupFolders.Checked
    $chkDupFileCount.Enabled = $radDupFolders.Checked
})

# ── GroupBox: Options  (y=144, h=44 → bottom 188) ─────────────────────────────
$grpDupOpts            = New-Group (T "grp.options") 10 144 638 44
$chkDupSubsites        = New-Check (T "chk.include.subsites")  10 18 192
$lblDupLib             = New-Object System.Windows.Forms.Label
$lblDupLib.Text        = T "lbl.library"
$lblDupLib.Location    = New-Object System.Drawing.Point(210, 18)
$lblDupLib.Size        = New-Object System.Drawing.Size(88, 22)
$lblDupLib.TextAlign   = "MiddleLeft"
$txtDupLib             = New-Object System.Windows.Forms.TextBox
$txtDupLib.Location    = New-Object System.Drawing.Point(300, 18)
$txtDupLib.Size        = New-Object System.Drawing.Size(326, 22)
$txtDupLib.PlaceholderText = T "ph.dup.lib"
$grpDupOpts.Controls.AddRange(@($chkDupSubsites, $lblDupLib, $txtDupLib))

# ── GroupBox: Format  (y=192, h=40 → bottom 232) ──────────────────────────────
$grpDupFmt             = New-Group (T "grp.export.fmt") 10 192 638 40
$radDupCSV             = New-Radio (T "rad.csv.perms")   10 16 130 $true
$radDupHTML            = New-Radio (T "rad.html.perms") 155 16 200
$grpDupFmt.Controls.AddRange(@($radDupCSV, $radDupHTML))

# ── Buttons  (y=236 → bottom 270, within 284px inner) ─────────────────────────
$btnScanDupes          = New-ActionBtn (T "btn.run.scan") 10 236 ([System.Drawing.Color]::FromArgb(136, 0, 21))
$btnOpenDupes          = New-Object System.Windows.Forms.Button
$btnOpenDupes.Text     = T "btn.open.results"
$btnOpenDupes.Location = New-Object System.Drawing.Point(175, 236)
$btnOpenDupes.Size     = New-Object System.Drawing.Size(130, 34)
$btnOpenDupes.Enabled  = $false

$tabDupes.Controls.AddRange(@($grpDupType, $grpDupCrit, $grpDupOpts, $grpDupFmt, $btnScanDupes, $btnOpenDupes))

# ══════════════════════════════════════════════════════════════════════════════
#   Tab 6 – Transfer
# ══════════════════════════════════════════════════════════════════════════════
$tabTransfer      = New-Object System.Windows.Forms.TabPage
$tabTransfer.Text = T "tab.transfer"

# ── GroupBox: Source (y=4, h=74) ─────────────────────────────────────────────
$grpXferSrc           = New-Group (T "grp.xfer.source") 10 4 620 74

$lblXferSrcSite       = New-Object System.Windows.Forms.Label
$lblXferSrcSite.Text  = T "lbl.xfer.site"
$lblXferSrcSite.Location = New-Object System.Drawing.Point(10, 20)
$lblXferSrcSite.Size  = New-Object System.Drawing.Size(145, 22)
$lblXferSrcSite.TextAlign = "MiddleLeft"

$txtXferSrcSite       = New-Object System.Windows.Forms.TextBox
$txtXferSrcSite.Location = New-Object System.Drawing.Point(160, 20)
$txtXferSrcSite.Size  = New-Object System.Drawing.Size(448, 22)
$txtXferSrcSite.PlaceholderText = T "ph.xfer.site"

$lblXferSrcLib        = New-Object System.Windows.Forms.Label
$lblXferSrcLib.Text   = T "lbl.xfer.library"
$lblXferSrcLib.Location = New-Object System.Drawing.Point(10, 46)
$lblXferSrcLib.Size   = New-Object System.Drawing.Size(145, 22)
$lblXferSrcLib.TextAlign = "MiddleLeft"

$txtXferSrcLib        = New-Object System.Windows.Forms.TextBox
$txtXferSrcLib.Location = New-Object System.Drawing.Point(160, 46)
$txtXferSrcLib.Size   = New-Object System.Drawing.Size(448, 22)
$txtXferSrcLib.PlaceholderText = T "ph.xfer.library"

$grpXferSrc.Controls.AddRange(@($lblXferSrcSite, $txtXferSrcSite, $lblXferSrcLib, $txtXferSrcLib))

# ── GroupBox: Destination (y=82, h=74) ───────────────────────────────────────
$grpXferDst           = New-Group (T "grp.xfer.dest") 10 82 620 74

$lblXferDstSite       = New-Object System.Windows.Forms.Label
$lblXferDstSite.Text  = T "lbl.xfer.site"
$lblXferDstSite.Location = New-Object System.Drawing.Point(10, 20)
$lblXferDstSite.Size  = New-Object System.Drawing.Size(145, 22)
$lblXferDstSite.TextAlign = "MiddleLeft"

$txtXferDstSite       = New-Object System.Windows.Forms.TextBox
$txtXferDstSite.Location = New-Object System.Drawing.Point(160, 20)
$txtXferDstSite.Size  = New-Object System.Drawing.Size(448, 22)
$txtXferDstSite.PlaceholderText = T "ph.xfer.site"

$lblXferDstLib        = New-Object System.Windows.Forms.Label
$lblXferDstLib.Text   = T "lbl.xfer.library"
$lblXferDstLib.Location = New-Object System.Drawing.Point(10, 46)
$lblXferDstLib.Size   = New-Object System.Drawing.Size(145, 22)
$lblXferDstLib.TextAlign = "MiddleLeft"

$txtXferDstLib        = New-Object System.Windows.Forms.TextBox
$txtXferDstLib.Location = New-Object System.Drawing.Point(160, 46)
$txtXferDstLib.Size   = New-Object System.Drawing.Size(448, 22)
$txtXferDstLib.PlaceholderText = T "ph.xfer.library"

$grpXferDst.Controls.AddRange(@($lblXferDstSite, $txtXferDstSite, $lblXferDstLib, $txtXferDstLib))

# ── GroupBox: Options (y=160, h=96) ──────────────────────────────────────────
$grpXferOpts          = New-Group (T "grp.xfer.options") 10 160 620 96

$chkXferRecursive     = New-Check (T "chk.xfer.recursive") 10 18 250 $true
$chkXferOverwrite     = New-Check (T "chk.xfer.overwrite") 270 18 180
$chkXferCreateFolders = New-Check (T "chk.xfer.create.folders") 460 18 155 $true

$lblXferFmt           = New-Object System.Windows.Forms.Label
$lblXferFmt.Text      = T "lbl.xfer.report.fmt"
$lblXferFmt.Location  = New-Object System.Drawing.Point(10, 42)
$lblXferFmt.Size      = New-Object System.Drawing.Size(55, 20)

$radXferCsv           = New-Radio "CSV" 68 42 55 $true
$radXferHtml          = New-Radio "HTML" 125 42 60 $false

$btnXferCsvImport     = New-Object System.Windows.Forms.Button
$btnXferCsvImport.Text     = T "btn.xfer.csv"
$btnXferCsvImport.Location = New-Object System.Drawing.Point(250, 40)
$btnXferCsvImport.Size     = New-Object System.Drawing.Size(118, 24)

$lblXferCsvInfo       = New-Object System.Windows.Forms.Label
$lblXferCsvInfo.Text  = ""
$lblXferCsvInfo.Location = New-Object System.Drawing.Point(374, 43)
$lblXferCsvInfo.Size  = New-Object System.Drawing.Size(175, 18)
$lblXferCsvInfo.ForeColor = [System.Drawing.Color]::FromArgb(0, 120, 212)

$btnXferCsvClear      = New-Object System.Windows.Forms.Button
$btnXferCsvClear.Text     = T "btn.xfer.csv.clear"
$btnXferCsvClear.Location = New-Object System.Drawing.Point(554, 40)
$btnXferCsvClear.Size     = New-Object System.Drawing.Size(55, 24)
$btnXferCsvClear.Visible  = $false

$lblXferNote          = New-Object System.Windows.Forms.Label
$lblXferNote.Text     = T "xfer.note"
$lblXferNote.Location = New-Object System.Drawing.Point(10, 72)
$lblXferNote.Size     = New-Object System.Drawing.Size(600, 16)
$lblXferNote.Font     = New-Object System.Drawing.Font("Segoe UI", 8, [System.Drawing.FontStyle]::Italic)
$lblXferNote.ForeColor = [System.Drawing.Color]::DimGray

$grpXferOpts.Controls.AddRange(@($chkXferRecursive, $chkXferOverwrite, $chkXferCreateFolders,
    $lblXferFmt, $radXferCsv, $radXferHtml,
    $btnXferCsvImport, $lblXferCsvInfo, $btnXferCsvClear,
    $lblXferNote))

# ── Buttons (y=260) ──────────────────────────────────────────────────────────
$btnXferStart         = New-ActionBtn (T "btn.xfer.start") 10 260 ([System.Drawing.Color]::FromArgb(0, 120, 60))

$btnXferVerify        = New-Object System.Windows.Forms.Button
$btnXferVerify.Text   = T "btn.xfer.verify"
$btnXferVerify.Location = New-Object System.Drawing.Point(175, 260)
$btnXferVerify.Size   = New-Object System.Drawing.Size(130, 34)
$btnXferVerify.BackColor = [System.Drawing.Color]::FromArgb(0, 120, 212)
$btnXferVerify.ForeColor = [System.Drawing.Color]::White
$btnXferVerify.FlatStyle = "Flat"
$btnXferVerify.Font   = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)

$btnXferOpen          = New-Object System.Windows.Forms.Button
$btnXferOpen.Text     = T "btn.xfer.open"
$btnXferOpen.Location = New-Object System.Drawing.Point(315, 260)
$btnXferOpen.Size     = New-Object System.Drawing.Size(130, 34)
$btnXferOpen.Enabled  = $false

$tabTransfer.Controls.AddRange(@($grpXferSrc, $grpXferDst, $grpXferOpts, $btnXferStart, $btnXferVerify, $btnXferOpen))

# ══════════════════════════════════════════════════════════════════════════════
#   Tab 7 – Bulk Create
# ══════════════════════════════════════════════════════════════════════════════
$tabBulk      = New-Object System.Windows.Forms.TabPage
$tabBulk.Text = T "tab.bulk"

# ── GroupBox: Site list ──────────────────────────────────────────────────────
$grpBulkList       = New-Group (T "grp.bulk.list") 10 4 620 230

$lvBulk            = New-Object System.Windows.Forms.ListView
$lvBulk.Location   = New-Object System.Drawing.Point(10, 18)
$lvBulk.Size       = New-Object System.Drawing.Size(598, 168)
$lvBulk.View       = "Details"
$lvBulk.FullRowSelect = $true
$lvBulk.GridLines  = $true
$lvBulk.Font       = New-Object System.Drawing.Font("Segoe UI", 8.5)
$lvBulk.Columns.Add((T "bulk.col.name"),    120) | Out-Null
$lvBulk.Columns.Add((T "bulk.col.alias"),    90) | Out-Null
$lvBulk.Columns.Add((T "bulk.col.type"),     60) | Out-Null
$lvBulk.Columns.Add((T "bulk.col.template"), 90) | Out-Null
$lvBulk.Columns.Add((T "bulk.col.owners"),  110) | Out-Null
$lvBulk.Columns.Add((T "bulk.col.members"), 110) | Out-Null

$btnBulkAdd        = New-Object System.Windows.Forms.Button
$btnBulkAdd.Text   = T "btn.bulk.add"
$btnBulkAdd.Location = New-Object System.Drawing.Point(10, 192)
$btnBulkAdd.Size   = New-Object System.Drawing.Size(120, 28)

$btnBulkCsv        = New-Object System.Windows.Forms.Button
$btnBulkCsv.Text   = T "btn.bulk.csv"
$btnBulkCsv.Location = New-Object System.Drawing.Point(138, 192)
$btnBulkCsv.Size   = New-Object System.Drawing.Size(120, 28)

$btnBulkRemove     = New-Object System.Windows.Forms.Button
$btnBulkRemove.Text = T "btn.bulk.remove"
$btnBulkRemove.Location = New-Object System.Drawing.Point(266, 192)
$btnBulkRemove.Size = New-Object System.Drawing.Size(90, 28)

$btnBulkClear      = New-Object System.Windows.Forms.Button
$btnBulkClear.Text = T "btn.bulk.clear"
$btnBulkClear.Location = New-Object System.Drawing.Point(364, 192)
$btnBulkClear.Size = New-Object System.Drawing.Size(90, 28)

$grpBulkList.Controls.AddRange(@($lvBulk, $btnBulkAdd, $btnBulkCsv, $btnBulkRemove, $btnBulkClear))

# ── Create button ────────────────────────────────────────────────────────────
$btnBulkCreate     = New-ActionBtn (T "btn.bulk.create") 10 240 ([System.Drawing.Color]::FromArgb(0, 120, 60))
$btnBulkCreate.Size = New-Object System.Drawing.Size(200, 34)

$tabBulk.Controls.AddRange(@($grpBulkList, $btnBulkCreate))

# ══════════════════════════════════════════════════════════════════════════════
#   Tab 8 – Structure (folder tree from CSV)
# ══════════════════════════════════════════════════════════════════════════════
$tabStruct      = New-Object System.Windows.Forms.TabPage
$tabStruct.Text = T "tab.structure"

# ── CSV import + target (single row) ───────────────────────────────────────
$grpStructCsv       = New-Group (T "grp.struct.csv") 10 4 620 52

$lblStructDesc      = New-Object System.Windows.Forms.Label
$lblStructDesc.Text     = T "lbl.struct.desc"
$lblStructDesc.Location = New-Object System.Drawing.Point(10, 20)
$lblStructDesc.Size     = New-Object System.Drawing.Size(460, 20)

$btnStructCsv       = New-Object System.Windows.Forms.Button
$btnStructCsv.Text     = T "btn.struct.csv"
$btnStructCsv.Location = New-Object System.Drawing.Point(490, 18)
$btnStructCsv.Size     = New-Object System.Drawing.Size(118, 26)

$grpStructCsv.Controls.AddRange(@($lblStructDesc, $btnStructCsv))

# ── Preview ────────────────────────────────────────────────────────────────
$grpStructPreview       = New-Group (T "grp.struct.preview") 10 58 620 148

$tvStruct               = New-Object System.Windows.Forms.TreeView
$tvStruct.Location      = New-Object System.Drawing.Point(10, 18)
$tvStruct.Size          = New-Object System.Drawing.Size(598, 120)
$tvStruct.Font          = New-Object System.Drawing.Font("Segoe UI", 9)
$tvStruct.ShowLines     = $true
$tvStruct.ShowPlusMinus = $true

$grpStructPreview.Controls.Add($tvStruct)

# ── Target + Buttons (single row) ─────────────────────────────────────────
$lblStructLib          = New-Object System.Windows.Forms.Label
$lblStructLib.Text     = T "lbl.struct.library"
$lblStructLib.Location = New-Object System.Drawing.Point(12, 214)
$lblStructLib.Size     = New-Object System.Drawing.Size(110, 20)

$txtStructLib          = New-Object System.Windows.Forms.TextBox
$txtStructLib.Location = New-Object System.Drawing.Point(124, 212)
$txtStructLib.Size     = New-Object System.Drawing.Size(200, 22)
$txtStructLib.PlaceholderText = T "ph.struct.library"

$btnStructCreate     = New-ActionBtn (T "btn.struct.create") 340 208 ([System.Drawing.Color]::FromArgb(0, 120, 212))
$btnStructCreate.Size = New-Object System.Drawing.Size(180, 30)

$btnStructClear      = New-Object System.Windows.Forms.Button
$btnStructClear.Text     = T "btn.struct.clear"
$btnStructClear.Location = New-Object System.Drawing.Point(528, 208)
$btnStructClear.Size     = New-Object System.Drawing.Size(90, 30)

$tabStruct.Controls.AddRange(@($grpStructCsv, $grpStructPreview, $lblStructLib, $txtStructLib, $btnStructCreate, $btnStructClear))

# ══════════════════════════════════════════════════════════════════════════════
#   Tab 9 – Version Cleanup
# ══════════════════════════════════════════════════════════════════════════════
$tabVersions      = New-Object System.Windows.Forms.TabPage
$tabVersions.Text = T "tab.versions"
$tabVersions.BackColor = [System.Drawing.Color]::WhiteSmoke

# ── Versions to keep ─────────────────────────────────────────────────────────
$grpVerKeep       = New-Group (T "grp.ver.keep") 10 4 620 110

$lblVerCount      = New-Object System.Windows.Forms.Label
$lblVerCount.Text     = T "lbl.ver.count"
$lblVerCount.Location = New-Object System.Drawing.Point(10, 22)
$lblVerCount.Size     = New-Object System.Drawing.Size(220, 20)

$nudVerCount      = New-Object System.Windows.Forms.NumericUpDown
$nudVerCount.Location = New-Object System.Drawing.Point(235, 20)
$nudVerCount.Size     = New-Object System.Drawing.Size(70, 22)
$nudVerCount.Minimum  = 0
$nudVerCount.Maximum  = 500
$nudVerCount.Value    = 5

$chkVerDate       = New-Check (T "chk.ver.date") 10 50 250 $false

$radVerBefore     = New-Radio (T "rad.ver.before") 30 74 200 $true
$radVerBefore.Enabled = $false
$radVerAfter      = New-Radio (T "rad.ver.after") 30 96 200 $false
$radVerAfter.Enabled  = $false

$dtpVer           = New-Object System.Windows.Forms.DateTimePicker
$dtpVer.Location  = New-Object System.Drawing.Point(235, 74)
$dtpVer.Size      = New-Object System.Drawing.Size(150, 22)
$dtpVer.Format    = [System.Windows.Forms.DateTimePickerFormat]::Short
$dtpVer.Enabled   = $false

$chkVerDate.Add_CheckedChanged({
    $on = $chkVerDate.Checked
    $radVerBefore.Enabled = $on
    $radVerAfter.Enabled  = $on
    $dtpVer.Enabled       = $on
})

$grpVerKeep.Controls.AddRange(@($lblVerCount, $nudVerCount, $chkVerDate, $radVerBefore, $radVerAfter, $dtpVer))

# ── Scope ─────────────────────────────────────────────────────────────────────
$grpVerScope       = New-Group (T "grp.ver.scope") 10 118 620 76

$lblVerLib         = New-Object System.Windows.Forms.Label
$lblVerLib.Text     = T "lbl.ver.library"
$lblVerLib.Location = New-Object System.Drawing.Point(10, 22)
$lblVerLib.Size     = New-Object System.Drawing.Size(150, 20)

$txtVerLib         = New-Object System.Windows.Forms.TextBox
$txtVerLib.Location = New-Object System.Drawing.Point(164, 20)
$txtVerLib.Size     = New-Object System.Drawing.Size(230, 22)
$txtVerLib.PlaceholderText = T "ph.ver.library"

$chkVerRecursive   = New-Check (T "chk.ver.recursive") 10 48 260 $true
$chkVerSubsites    = New-Check (T "chk.ver.subsites") 280 48 200 $false

$grpVerScope.Controls.AddRange(@($lblVerLib, $txtVerLib, $chkVerRecursive, $chkVerSubsites))

# ── Options + Buttons ─────────────────────────────────────────────────────────
$chkVerDryRun      = New-Check (T "chk.ver.dryrun") 12 200 350 $true

$btnVerRun         = New-ActionBtn (T "btn.ver.run") 10 228 ([System.Drawing.Color]::FromArgb(180, 60, 20))
$btnVerRun.Size    = New-Object System.Drawing.Size(180, 30)

$btnVerOpen        = New-Object System.Windows.Forms.Button
$btnVerOpen.Text     = T "btn.ver.open"
$btnVerOpen.Location = New-Object System.Drawing.Point(200, 228)
$btnVerOpen.Size     = New-Object System.Drawing.Size(130, 30)
$btnVerOpen.Enabled  = $false

$tabVersions.Controls.AddRange(@($grpVerKeep, $grpVerScope, $chkVerDryRun, $btnVerRun, $btnVerOpen))

$tabs.TabPages.AddRange(@($tabPerms, $tabStorage, $tabTemplates, $tabSearch, $tabDupes, $tabTransfer, $tabBulk, $tabStruct, $tabVersions))

# ── Progress bar ───────────────────────────────────────────────────────────────
$progressBar           = New-Object System.Windows.Forms.ProgressBar
$progressBar.Location  = New-Object System.Drawing.Point(20, 540)
$progressBar.Size      = New-Object System.Drawing.Size(642, 16)
$progressBar.Style     = "Continuous"
$progressBar.Minimum   = 0
$progressBar.Maximum   = 100
$progressBar.Value     = 0
$progressBar.Visible   = $false

# Animation timer: sweeps 0→100 then resets, driven by UI-thread timer
$script:_AnimTimer          = New-Object System.Windows.Forms.Timer
$script:_AnimTimer.Interval = 30   # ~33 fps
$script:_AnimTimer.Add_Tick({
    $v = $progressBar.Value + 2
    if ($v -gt 100) { $v = 0 }
    $progressBar.Value = $v
})

function Start-ProgressAnim {
    $progressBar.Value   = 0
    $progressBar.Visible = $true
    $script:_AnimTimer.Start()
}
function Stop-ProgressAnim {
    $script:_AnimTimer.Stop()
    $progressBar.Value   = 0
    $progressBar.Visible = $false
}

# ── Log ────────────────────────────────────────────────────────────────────────
$lblLog                = New-Object System.Windows.Forms.Label
$lblLog.Text           = T "lbl.log"
$lblLog.Location       = New-Object System.Drawing.Point(20, 564)
$lblLog.Size           = New-Object System.Drawing.Size(60, 20)

$txtLog                = New-Object System.Windows.Forms.RichTextBox
$txtLog.Location       = New-Object System.Drawing.Point(20, 584)
$txtLog.Size           = New-Object System.Drawing.Size(642, 208)
$txtLog.ReadOnly       = $true
$txtLog.BackColor      = [System.Drawing.Color]::Black
$txtLog.ForeColor      = [System.Drawing.Color]::LightGreen
$txtLog.Font           = New-Object System.Drawing.Font("Consolas", 9)
$txtLog.ScrollBars     = "Vertical"

$script:LogBox         = $txtLog
$script:txtClientId    = $txtClientId
$script:txtSiteURL     = $txtSiteURL
$script:txtTenantUrl   = $txtTenantUrl
$script:cboProfile     = $cboProfile
$script:btnBrowseSites = $btnBrowseSites
$script:Profiles       = @()
$script:SelectedSites  = @()
$script:_SiteCache     = @()

# Si l'utilisateur re-saisit manuellement, effacer la multi-sélection du picker
$txtSiteURL.Add_TextChanged({
    if ($txtSiteURL.Enabled -and $script:SelectedSites.Count -gt 0) {
        $script:SelectedSites = @()
        $btnBrowseSites.Text  = T "btn.view.sites"
    }
})

$form.Controls.AddRange(@(
    $menuStrip,
    $lblProfile, $cboProfile,
    $btnProfileNew, $btnProfileSave, $btnProfileRename, $btnProfileDelete,
    $lblTenantUrl, $txtTenantUrl, $btnBrowseSites,
    $lblClientId, $txtClientId, $btnRegisterApp,
    $lblSiteURL,  $txtSiteURL,
    $lblOutput,   $txtOutput,   $btnBrowse,
    $sep, $tabs,
    $progressBar,
    $lblLog, $txtLog
))

# ── i18n control registration ──────────────────────────────────────────────────
$script:i18nMap = [System.Collections.Generic.Dictionary[string,object]]::new()
$script:i18nTabs = [System.Collections.Generic.Dictionary[string,object]]::new()
$script:i18nMenus = [System.Collections.Generic.Dictionary[string,object]]::new()

$_reg = {
    param($dict, $ctrl, $key)
    $dict[[System.Guid]::NewGuid().ToString()] = [PSCustomObject]@{ Control = $ctrl; Key = $key }
}

# Main labels & buttons
& $_reg $script:i18nMap $lblProfile        "profile"
& $_reg $script:i18nMap $btnProfileNew     "btn.new"
& $_reg $script:i18nMap $btnProfileSave    "btn.save"
& $_reg $script:i18nMap $btnProfileRename  "btn.rename"
& $_reg $script:i18nMap $btnProfileDelete  "btn.delete"
& $_reg $script:i18nMap $btnBrowseSites    "btn.view.sites"
& $_reg $script:i18nMap $btnRegisterApp    "btn.register.app"
& $_reg $script:i18nMap $lblTenantUrl      "tenant.url"
& $_reg $script:i18nMap $lblClientId       "client.id"
& $_reg $script:i18nMap $lblSiteURL        "site.url"
& $_reg $script:i18nMap $lblOutput         "output.folder"
& $_reg $script:i18nMap $btnBrowse         "btn.browse"
& $_reg $script:i18nMap $lblLog            "lbl.log"

# Permissions tab controls
& $_reg $script:i18nMap $grpPermOpts       "grp.scan.opts"
& $_reg $script:i18nMap $chkScanFolders    "chk.scan.folders"
& $_reg $script:i18nMap $chkRecursive      "chk.recursive"
& $_reg $script:i18nMap $lblPermDepth      "lbl.folder.depth"
& $_reg $script:i18nMap $chkPermMaxDepth   "chk.max.depth"
& $_reg $script:i18nMap $chkInheritedPerms "chk.inherited.perms"
& $_reg $script:i18nMap $grpPermFmt        "grp.export.fmt"
& $_reg $script:i18nMap $radPermCSV        "rad.csv.perms"
& $_reg $script:i18nMap $radPermHTML       "rad.html.perms"
& $_reg $script:i18nMap $btnGenPerms       "btn.gen.perms"
& $_reg $script:i18nMap $btnOpenPerms      "btn.open.perms"

# Storage tab controls
& $_reg $script:i18nMap $grpStorOpts       "grp.scan.opts"
& $_reg $script:i18nMap $chkStorPerLib     "chk.per.lib"
& $_reg $script:i18nMap $chkStorSubsites   "chk.subsites"
& $_reg $script:i18nMap $lblDepth          "lbl.folder.depth"
& $_reg $script:i18nMap $chkMaxDepth       "chk.max.depth"
& $_reg $script:i18nMap $lblStorNote       "stor.note"
& $_reg $script:i18nMap $grpStorFmt        "grp.export.fmt"
& $_reg $script:i18nMap $radStorCSV        "rad.csv.perms"
& $_reg $script:i18nMap $radStorHTML       "rad.html.perms"
& $_reg $script:i18nMap $btnGenStorage     "btn.gen.storage"
& $_reg $script:i18nMap $btnOpenStorage    "btn.open.storage"

# Templates tab controls
& $_reg $script:i18nMap $lblTplDesc        "tpl.desc"
& $_reg $script:i18nMap $btnOpenTplMgr     "btn.manage.tpl"

# Search tab controls
& $_reg $script:i18nMap $grpSearchFilters  "grp.search.filters"
& $_reg $script:i18nMap $lblSrchExt        "lbl.extensions"
& $_reg $script:i18nMap $lblSrchRegex      "lbl.regex"
& $_reg $script:i18nMap $chkSrchCrA       "chk.created.after"
& $_reg $script:i18nMap $chkSrchCrB       "chk.created.before"
& $_reg $script:i18nMap $chkSrchModA      "chk.modified.after"
& $_reg $script:i18nMap $chkSrchModB      "chk.modified.before"
& $_reg $script:i18nMap $lblSrchCrBy      "lbl.created.by"
& $_reg $script:i18nMap $lblSrchModBy     "lbl.modified.by"
& $_reg $script:i18nMap $lblSrchLib       "lbl.library"
& $_reg $script:i18nMap $grpSearchFmt     "grp.search.fmt"
& $_reg $script:i18nMap $lblSrchMax       "lbl.max.results"
& $_reg $script:i18nMap $btnSearch        "btn.run.search"
& $_reg $script:i18nMap $btnOpenSearch    "btn.open.search"

# Duplicates tab controls
& $_reg $script:i18nMap $grpDupType       "grp.dup.type"
& $_reg $script:i18nMap $radDupFiles      "rad.dup.files"
& $_reg $script:i18nMap $radDupFolders    "rad.dup.folders"
& $_reg $script:i18nMap $grpDupCrit       "grp.dup.criteria"
& $_reg $script:i18nMap $lblDupNote       "lbl.dup.note"
& $_reg $script:i18nMap $chkDupSize       "chk.dup.size"
& $_reg $script:i18nMap $chkDupCreated    "chk.dup.created"
& $_reg $script:i18nMap $chkDupModified   "chk.dup.modified"
& $_reg $script:i18nMap $chkDupSubCount   "chk.dup.subfolders"
& $_reg $script:i18nMap $chkDupFileCount  "chk.dup.filecount"
& $_reg $script:i18nMap $grpDupOpts       "grp.options"
& $_reg $script:i18nMap $chkDupSubsites   "chk.include.subsites"
& $_reg $script:i18nMap $lblDupLib        "lbl.library"
& $_reg $script:i18nMap $btnScanDupes     "btn.run.scan"
& $_reg $script:i18nMap $btnOpenDupes     "btn.open.results"

# Transfer tab controls
& $_reg $script:i18nMap $grpXferSrc       "grp.xfer.source"
& $_reg $script:i18nMap $lblXferSrcSite   "lbl.xfer.site"
& $_reg $script:i18nMap $lblXferSrcLib    "lbl.xfer.library"
& $_reg $script:i18nMap $grpXferDst       "grp.xfer.dest"
& $_reg $script:i18nMap $lblXferDstSite   "lbl.xfer.site"
& $_reg $script:i18nMap $lblXferDstLib    "lbl.xfer.library"
& $_reg $script:i18nMap $grpXferOpts      "grp.xfer.options"
& $_reg $script:i18nMap $chkXferRecursive "chk.xfer.recursive"
& $_reg $script:i18nMap $chkXferOverwrite    "chk.xfer.overwrite"
& $_reg $script:i18nMap $chkXferCreateFolders "chk.xfer.create.folders"
& $_reg $script:i18nMap $lblXferFmt          "lbl.xfer.report.fmt"
& $_reg $script:i18nMap $btnXferCsvImport    "btn.xfer.csv"
& $_reg $script:i18nMap $btnXferCsvClear     "btn.xfer.csv.clear"
& $_reg $script:i18nMap $lblXferNote         "xfer.note"
& $_reg $script:i18nMap $btnXferStart        "btn.xfer.start"
& $_reg $script:i18nMap $btnXferVerify       "btn.xfer.verify"
& $_reg $script:i18nMap $btnXferOpen         "btn.xfer.open"

# Bulk Create tab controls
& $_reg $script:i18nMap $grpBulkList      "grp.bulk.list"
& $_reg $script:i18nMap $btnBulkAdd       "btn.bulk.add"
& $_reg $script:i18nMap $btnBulkCsv       "btn.bulk.csv"
& $_reg $script:i18nMap $btnBulkRemove    "btn.bulk.remove"
& $_reg $script:i18nMap $btnBulkClear     "btn.bulk.clear"
& $_reg $script:i18nMap $btnBulkCreate    "btn.bulk.create"
& $_reg $script:i18nMap $lblStructDesc   "lbl.struct.desc"
& $_reg $script:i18nMap $btnStructCsv    "btn.struct.csv"
& $_reg $script:i18nMap $lblStructLib    "lbl.struct.library"
& $_reg $script:i18nMap $btnStructCreate "btn.struct.create"
& $_reg $script:i18nMap $btnStructClear  "btn.struct.clear"
# Version Cleanup tab
& $_reg $script:i18nMap $lblVerCount    "lbl.ver.count"
& $_reg $script:i18nMap $chkVerDate     "chk.ver.date"
& $_reg $script:i18nMap $radVerBefore   "rad.ver.before"
& $_reg $script:i18nMap $radVerAfter    "rad.ver.after"
& $_reg $script:i18nMap $lblVerLib      "lbl.ver.library"
& $_reg $script:i18nMap $chkVerRecursive "chk.ver.recursive"
& $_reg $script:i18nMap $chkVerSubsites "chk.ver.subsites"
& $_reg $script:i18nMap $chkVerDryRun   "chk.ver.dryrun"
& $_reg $script:i18nMap $btnVerRun      "btn.ver.run"
& $_reg $script:i18nMap $btnVerOpen     "btn.ver.open"
& $_reg $script:i18nMap $grpVerKeep     "grp.ver.keep"
& $_reg $script:i18nMap $grpVerScope    "grp.ver.scope"

# Tab pages
& $_reg $script:i18nTabs $tabPerms        "tab.perms"
& $_reg $script:i18nTabs $tabStorage      "tab.storage"
& $_reg $script:i18nTabs $tabTemplates    "tab.templates"
& $_reg $script:i18nTabs $tabSearch       "tab.search"
& $_reg $script:i18nTabs $tabDupes        "tab.dupes"
& $_reg $script:i18nTabs $tabTransfer    "tab.transfer"
& $_reg $script:i18nTabs $tabBulk       "tab.bulk"
& $_reg $script:i18nTabs $tabStruct    "tab.structure"
& $_reg $script:i18nTabs $tabVersions "tab.versions"

# Menu items
& $_reg $script:i18nMenus $menuSettings   "menu.settings"
& $_reg $script:i18nMenus $menuJsonFolder "menu.json.folder"
& $_reg $script:i18nMenus $menuLang       "menu.language"

# Placeholder texts
$script:i18nPlaceholders = [System.Collections.Generic.Dictionary[string,object]]::new()
& $_reg $script:i18nPlaceholders $txtSrchExt    "ph.extensions"
& $_reg $script:i18nPlaceholders $txtSrchRegex  "ph.regex"
& $_reg $script:i18nPlaceholders $txtSrchCrBy   "ph.created.by"
& $_reg $script:i18nPlaceholders $txtSrchModBy  "ph.modified.by"
& $_reg $script:i18nPlaceholders $txtSrchLib    "ph.library"
& $_reg $script:i18nPlaceholders $txtDupLib     "ph.dup.lib"
& $_reg $script:i18nPlaceholders $txtStructLib  "ph.struct.library"
& $_reg $script:i18nPlaceholders $txtXferSrcSite "ph.xfer.site"
& $_reg $script:i18nPlaceholders $txtXferSrcLib  "ph.xfer.library"
& $_reg $script:i18nPlaceholders $txtXferDstSite "ph.xfer.site"
& $_reg $script:i18nPlaceholders $txtXferDstLib  "ph.xfer.library"
& $_reg $script:i18nPlaceholders $txtVerLib     "ph.ver.library"

#endregion

#region ===== Event Handlers =====

# ── Profile Management ─────────────────────────────────────────────────────────
$cboProfile.Add_SelectedIndexChanged({
    $idx = $cboProfile.SelectedIndex
    Apply-Profile -idx $idx
})

$btnProfileNew.Add_Click({
    $name = Show-InputDialog -Prompt "Nom du profil :" -Title "Nouveau profil" -Default "Nouveau profil" -Owner $form
    if ([string]::IsNullOrWhiteSpace($name)) { return }
    $newProfile = [PSCustomObject]@{
        name      = $name
        clientId  = $txtClientId.Text.Trim()
        tenantUrl = $txtTenantUrl.Text.Trim()
    }
    $list = @($script:Profiles) + $newProfile
    Save-Profiles -Profiles $list
    Refresh-ProfileList
    $idx = $cboProfile.Items.IndexOf($name)
    if ($idx -ge 0) { $cboProfile.SelectedIndex = $idx }
})

$btnProfileSave.Add_Click({
    $idx = $cboProfile.SelectedIndex
    if ($idx -lt 0) {
        [System.Windows.Forms.MessageBox]::Show("Selectionnez d'abord un profil ou creez-en un nouveau.", "Aucun profil selectionne", "OK", "Warning")
        return
    }
    $script:Profiles[$idx].clientId  = $txtClientId.Text.Trim()
    if (-not $script:Profiles[$idx].PSObject.Properties['tenantUrl']) {
        $script:Profiles[$idx] | Add-Member -NotePropertyName tenantUrl -NotePropertyValue ""
    }
    $script:Profiles[$idx].tenantUrl = $txtTenantUrl.Text.Trim()
    Save-Profiles -Profiles $script:Profiles
    [System.Windows.Forms.MessageBox]::Show("Profil '$($script:Profiles[$idx].name)' sauvegarde.", "Sauvegarde", "OK", "Information")
})

$btnProfileRename.Add_Click({
    $idx = $cboProfile.SelectedIndex
    if ($idx -lt 0) { return }
    $oldName = $script:Profiles[$idx].name
    $newName = Show-InputDialog -Prompt "Nouveau nom du profil :" -Title "Renommer le profil" -Default $oldName -Owner $form
    if ([string]::IsNullOrWhiteSpace($newName) -or $newName -eq $oldName) { return }
    $script:Profiles[$idx].name = $newName
    Save-Profiles -Profiles $script:Profiles
    Refresh-ProfileList
    $idx2 = $cboProfile.Items.IndexOf($newName)
    if ($idx2 -ge 0) { $cboProfile.SelectedIndex = $idx2 }
})

$btnProfileDelete.Add_Click({
    $idx = $cboProfile.SelectedIndex
    if ($idx -lt 0) { return }
    $name = $script:Profiles[$idx].name
    $res = [System.Windows.Forms.MessageBox]::Show("Supprimer le profil '$name' ?", "Confirmer la suppression", "YesNo", "Warning")
    if ($res -ne "Yes") { return }
    $list = @($script:Profiles | Where-Object { $_.name -ne $name })
    Save-Profiles -Profiles $list
    Refresh-ProfileList
})

$btnBrowse.Add_Click({
    $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
    $dlg.SelectedPath = $txtOutput.Text
    if ($dlg.ShowDialog() -eq "OK") { $txtOutput.Text = $dlg.SelectedPath }
})

$menuJsonFolder.Add_Click({
    $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
    $dlg.Description  = T "dlg.json.folder.desc"
    $dlg.SelectedPath = if ($script:DataFolder -and (Test-Path $script:DataFolder)) {
        $script:DataFolder
    } else {
        if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
    }
    if ($dlg.ShowDialog() -ne "OK") { return }
    $newDir = $dlg.SelectedPath
    if (-not (Test-Path $newDir)) {
        $msg = (T "dlg.folder.not.found") -f $newDir
        $res = [System.Windows.Forms.MessageBox]::Show(
            $msg, (T "dlg.folder.not.found.title"), "YesNo", "Question")
        if ($res -eq "Yes") {
            try { New-Item -ItemType Directory -Path $newDir | Out-Null }
            catch {
                [System.Windows.Forms.MessageBox]::Show(
                    $_.Exception.Message, "Error", "OK", "Error")
                return
            }
        } else { return }
    }
    $script:DataFolder = $newDir
    Save-Settings -DataFolder $newDir -Lang $script:CurrentLang
    Refresh-ProfileList
    $n = (Load-Templates).Count
    $lblTplCount.Text = "$n $(T 'tpl.count')"
})

# ── Language menu handlers ─────────────────────────────────────────────────────
function Switch-AppLanguage([string]$code) {
    Load-Language $code
    Update-UILanguage
    foreach ($mi in $menuLang.DropDownItems) {
        if ($mi -is [System.Windows.Forms.ToolStripMenuItem]) {
            $mi.Checked = ($mi.Tag -eq $script:CurrentLang)
        }
    }
    Save-Settings -DataFolder $script:DataFolder -Lang $script:CurrentLang
    $n = (Load-Templates).Count
    $lblTplCount.Text = "$n $(T 'tpl.count')"
}

$menuLangEn.Add_Click({ Switch-AppLanguage "en" })
foreach ($mi in @($menuLang.DropDownItems | Where-Object { $_ -is [System.Windows.Forms.ToolStripMenuItem] -and $_.Tag -ne "en" })) {
    $mi.Add_Click({ Switch-AppLanguage $args[0].Tag })
}

$btnRegisterApp.Add_Click({
    $tenantUrl = $txtTenantUrl.Text.Trim()
    if ([string]::IsNullOrWhiteSpace($tenantUrl)) {
        [System.Windows.Forms.MessageBox]::Show(
            (T "reg.err.no.tenant"), (T "reg.title"), "OK", "Warning")
        return
    }
    $confirm = [System.Windows.Forms.MessageBox]::Show(
        ((T "reg.confirm") -f $tenantUrl),
        (T "reg.title"), "YesNo", "Question")
    if ($confirm -ne "Yes") { return }

    # ── Derive tenant identifier ──────────────────────────────────────────────
    if ($tenantUrl -match 'https://([^.]+)\.sharepoint\.com') {
        $tenantId = "$($Matches[1]).onmicrosoft.com"
    } else {
        [System.Windows.Forms.MessageBox]::Show(
            (T "reg.err.tenant"), (T "reg.title"), "OK", "Error")
        return
    }

    $btnRegisterApp.Enabled = $false
    $btnRegisterApp.Text    = T "reg.in.progress"
    Write-Log "Registering app on $tenantId ..."

    # ── Write a temp script and launch a real PowerShell console ──────────────
    $resultFile = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_RegResult.json"
    if (Test-Path $resultFile) { Remove-Item $resultFile -Force }
    $script:_regResultFile = $resultFile

    $scriptContent = @"
`$Host.UI.RawUI.WindowTitle = "SharePoint Toolbox - App Registration"
try {
    Import-Module PnP.PowerShell -ErrorAction Stop
} catch {
    Write-Host "ERROR: PnP.PowerShell module not found." -ForegroundColor Red
    Write-Host `$_.Exception.Message -ForegroundColor Red
    @{ Error = `$_.Exception.Message } | ConvertTo-Json | Set-Content -Path "$resultFile" -Encoding UTF8
    Read-Host "Press Enter to close"
    exit
}
Write-Host "Registering app on $tenantId ..." -ForegroundColor Cyan
Write-Host "A browser window will open for authentication." -ForegroundColor Yellow
Write-Host ""
try {
    `$result = Register-PnPEntraIDAppForInteractiveLogin ``
        -ApplicationName "SharePoint Toolbox" ``
        -Tenant "$tenantId"
    `$clientId = `$result.'AzureAppId/ClientId'
    if (`$clientId) {
        Write-Host "Success! Client ID: `$clientId" -ForegroundColor Green
    } else {
        Write-Host "WARNING: No Client ID returned." -ForegroundColor Yellow
    }
    @{ ClientId = `$clientId } | ConvertTo-Json | Set-Content -Path "$resultFile" -Encoding UTF8
} catch {
    Write-Host "ERROR: `$(`$_.Exception.Message)" -ForegroundColor Red
    @{ Error = `$_.Exception.Message } | ConvertTo-Json | Set-Content -Path "$resultFile" -Encoding UTF8
}
Read-Host "Press Enter to close"
"@
    $scriptFile = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_RegApp.ps1"
    $scriptContent | Set-Content -Path $scriptFile -Encoding UTF8

    $pwshPath = Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
    if (-not $pwshPath) {
        $btnRegisterApp.Enabled = [string]::IsNullOrWhiteSpace($txtClientId.Text)
        $btnRegisterApp.Text    = T "btn.register.app"
        Write-Log "PowerShell 7+ (pwsh) not found." "Red"
        [System.Windows.Forms.MessageBox]::Show(
            (T "reg.err.nopwsh"), (T "reg.title"), "OK", "Error")
        return
    }
    Start-Process $pwshPath -ArgumentList "-ExecutionPolicy Bypass -File `"$scriptFile`""

    # ── Timer polls for the result file ──────────────────────────────────────
    $tmr          = New-Object System.Windows.Forms.Timer
    $tmr.Interval = 500
    $script:_regTimer = $tmr

    $tmr.Add_Tick({
        if (Test-Path $script:_regResultFile) {
            $script:_regTimer.Stop(); $script:_regTimer.Dispose()
            $btnRegisterApp.Text    = T "btn.register.app"

            try {
                $res = Get-Content $script:_regResultFile -Raw | ConvertFrom-Json
                Remove-Item $script:_regResultFile -Force -ErrorAction SilentlyContinue
            } catch {
                Write-Log "Failed to read registration result." "Red"
                $btnRegisterApp.Enabled = [string]::IsNullOrWhiteSpace($txtClientId.Text)
                return
            }

            if ($res.Error) {
                Write-Log "App registration failed: $($res.Error)" "Red"
                $btnRegisterApp.Enabled = $true
                [System.Windows.Forms.MessageBox]::Show(
                    ((T "reg.err.failed") -f $res.Error),
                    (T "reg.title"), "OK", "Error")
            } elseif ($res.ClientId) {
                $script:txtClientId.Text = $res.ClientId
                Write-Log "App registered. Client ID: $($res.ClientId)"
                [System.Windows.Forms.MessageBox]::Show(
                    ((T "reg.success") -f $res.ClientId),
                    (T "reg.title"), "OK", "Information")
            } else {
                Write-Log "Registration returned no Client ID." "Red"
                $btnRegisterApp.Enabled = $true
                [System.Windows.Forms.MessageBox]::Show(
                    (T "reg.err.no.id"), (T "reg.title"), "OK", "Error")
            }
        } else {
            $dot = "." * (([System.DateTime]::Now.Second % 4) + 1)
            $btnRegisterApp.Text = (T "reg.in.progress") -replace '\.\.\.$', $dot
        }
    })
    $tmr.Start()
})

$btnBrowseSites.Add_Click({
    $tenantUrl = $txtTenantUrl.Text.Trim()
    $clientId  = $txtClientId.Text.Trim()
    if ([string]::IsNullOrWhiteSpace($tenantUrl)) {
        [System.Windows.Forms.MessageBox]::Show(
            "Veuillez renseigner le Tenant URL (ex: https://contoso.sharepoint.com).",
            "Tenant URL manquant", "OK", "Warning")
        return
    }
    if ([string]::IsNullOrWhiteSpace($clientId)) {
        [System.Windows.Forms.MessageBox]::Show(
            "Veuillez renseigner le Client ID.", "Client ID manquant", "OK", "Warning")
        return
    }
    $selected = Show-SitePicker -TenantUrl $tenantUrl -ClientId $clientId -Owner $form `
                                -InitialSites $script:_SiteCache `
                                -PreSelected  $script:SelectedSites
    if ($null -eq $selected) { return }   # Cancel — ne rien changer

    $script:SelectedSites = @($selected)
    $n = $selected.Count

    if ($n -gt 1) {
        # Plusieurs sites : griser le champ
        $txtSiteURL.Text      = ""
        $txtSiteURL.Enabled   = $false
        $txtSiteURL.BackColor = [System.Drawing.Color]::FromArgb(224, 224, 224)
        $btnBrowseSites.Text  = "Sites ($n)"
    } elseif ($n -eq 1) {
        $txtSiteURL.Text      = $selected[0]
        $txtSiteURL.Enabled   = $true
        $txtSiteURL.BackColor = [System.Drawing.Color]::White
        $btnBrowseSites.Text  = T "btn.view.sites"
    } else {
        # 0 sélectionnés (OK avec rien) — réinitialiser
        $txtSiteURL.Text      = ""
        $txtSiteURL.Enabled   = $true
        $txtSiteURL.BackColor = [System.Drawing.Color]::White
        $btnBrowseSites.Text  = T "btn.view.sites"
    }
})

# ── Permissions ────────────────────────────────────────────────────────────────
$btnGenPerms.Add_Click({
    if (-not (Validate-Inputs)) { return }

    # Build site list: picker selection takes priority, else txtSiteURL
    $siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
        @($script:SelectedSites)
    } else {
        @($txtSiteURL.Text.Trim())
    }
    $siteUrls = @($siteUrls | Where-Object { $_ })

    $script:pnpCiD         = $txtClientId.Text.Trim()
    $script:PermFormat      = if ($radPermHTML.Checked) { "HTML" } else { "CSV" }

    $script:PermFolderDepth = if (-not $chkScanFolders.Checked) { 0 }
                              elseif ($chkPermMaxDepth.Checked) { 999 }
                              else                              { [int]$nudPermDepth.Value }

    $btnGenPerms.Enabled   = $false
    $btnOpenPerms.Enabled  = $false
    $txtLog.Clear()
    Start-ProgressAnim

    $depthLabel = if ($script:PermFolderDepth -ge 999) { "Maximum" } elseif ($script:PermFolderDepth -eq 0) { "N/A (folder scan off)" } else { $script:PermFolderDepth }
    Write-Log "=== PERMISSIONS REPORT ===" "White"
    Write-Log "Sites        : $($siteUrls.Count)" "Gray"
    Write-Log "Format       : $($script:PermFormat)" "Gray"
    Write-Log "Folder depth : $depthLabel" "Gray"
    Write-Log ("-" * 52) "DarkGray"

    $lastFile = $null
    foreach ($SiteURL in $siteUrls) {
        $SiteName              = ($SiteURL -split '/')[-1]; if (!$SiteName) { $SiteName = "root" }
        $csvPath               = Join-Path $txtOutput.Text "$SiteName-Permissions.csv"
        $script:PermOutputFile = $csvPath
        $script:AllPermissions = @()

        Write-Log ""
        Write-Log "--- Site: $SiteURL" "White"

        $params = @{ SiteURL = $SiteURL; ReportFile = $csvPath }
        if ($chkScanFolders.Checked)    { $params.ScanFolders                = $true }
        if ($chkRecursive.Checked)      { $params.Recursive                   = $true }
        if ($chkInheritedPerms.Checked) { $params.IncludeInheritedPermissions = $true }

        try {
            Generate-PnPSitePermissionRpt @params
            Write-Log ("-" * 52) "DarkGray"
            Write-Log "Done!  $($script:AllPermissions.Count) entries -- Saved: $csvPath" "Cyan"
            $lastFile = $csvPath
            $btnOpenPerms.Enabled = $true
        }
        catch { Write-Log "Failed ($SiteURL): $($_.Exception.Message)" "Red" }
    }

    $script:PermOutputFile = $lastFile
    $btnGenPerms.Enabled = $true
    Stop-ProgressAnim
})

$btnOpenPerms.Add_Click({
    if ($script:PermOutputFile -and (Test-Path $script:PermOutputFile)) {
        Start-Process $script:PermOutputFile
    }
})

# ── Storage ────────────────────────────────────────────────────────────────────

# Background worker scriptblock (shared across all site scans)
$script:_StorBgWork = {
    param($SiteURL, $ClientId, $InclSub, $PerLib, $FolderDepth, $Sync)

    $script:_bgSync  = $Sync
    $script:_bgDepth = $FolderDepth

    function BgLog([string]$msg, [string]$color = "LightGreen") {
        $script:_bgSync.Queue.Enqueue([PSCustomObject]@{ Text = $msg; Color = $color })
    }
    function Format-Bytes([long]$b) {
        if ($b -ge 1GB) { return "$([math]::Round($b/1GB,2)) GB" }
        if ($b -ge 1MB) { return "$([math]::Round($b/1MB,1)) MB" }
        if ($b -ge 1KB) { return "$([math]::Round($b/1KB,0)) KB" }
        return "$b B"
    }
    function Collect-FolderStorage([string]$SiteRelUrl, [string]$WebBaseUrl, [int]$Depth) {
        if ($Depth -ge $script:_bgDepth) { return @() }
        $out = [System.Collections.Generic.List[object]]::new()
        try {
            $items = Get-PnPFolderItem -FolderSiteRelativeUrl $SiteRelUrl -ItemType Folder -ErrorAction SilentlyContinue
            foreach ($fi in $items) {
                $childUrl = "$SiteRelUrl/$($fi.Name)"
                try {
                    $sm  = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $childUrl -ErrorAction SilentlyContinue
                    $sub = Collect-FolderStorage -SiteRelUrl $childUrl -WebBaseUrl $WebBaseUrl -Depth ($Depth + 1)
                    $out.Add([PSCustomObject]@{
                        Name             = $fi.Name
                        URL              = "$($WebBaseUrl.TrimEnd('/'))/$childUrl"
                        ItemCount        = $sm.TotalFileCount
                        SizeBytes        = $sm.TotalSize
                        VersionSizeBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize)
                        LastModified     = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
                        SubFolders       = $sub
                    })
                } catch {}
            }
        } catch {}
        return @($out)
    }
    $script:_bgResults = [System.Collections.Generic.List[object]]::new()
    function Collect-WebStorage([string]$WebUrl, [bool]$PerLib, [bool]$InclSub, [string]$ClientId) {
        Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $ClientId
        $web  = Get-PnPWeb
        $wUrl = $web.Url
        $wSrl = $web.ServerRelativeUrl.TrimEnd('/')
        BgLog "Site : $($web.Title)" "Yellow"
        if ($PerLib) {
            $lists = Get-PnPList | Where-Object { $_.BaseType -eq "DocumentLibrary" -and !$_.Hidden }
            BgLog "`t$($lists.Count) bibliotheque(s) trouvee(s)" "Gray"
            foreach ($list in $lists) {
                $rf = Get-PnPProperty -ClientObject $list -Property RootFolder
                try {
                    $srl      = $rf.ServerRelativeUrl.Substring($wSrl.Length).TrimStart('/')
                    $sm       = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl $srl
                    $libUrl   = "$($wUrl.TrimEnd('/'))/$srl"
                    $subs     = Collect-FolderStorage -SiteRelUrl $srl -WebBaseUrl $wUrl -Depth 0
                    $verBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize)
                    $script:_bgResults.Add([PSCustomObject]@{
                        SiteTitle        = $web.Title
                        SiteURL          = $wUrl
                        Library          = $list.Title
                        LibraryURL       = $libUrl
                        ItemCount        = $sm.TotalFileCount
                        SizeBytes        = $sm.TotalSize
                        VersionSizeBytes = $verBytes
                        SizeMB           = [math]::Round($sm.TotalSize / 1MB, 1)
                        VersionSizeMB    = [math]::Round($verBytes / 1MB, 1)
                        SizeGB           = [math]::Round($sm.TotalSize / 1GB, 3)
                        LastModified     = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
                        SubFolders       = $subs
                    })
                    BgLog "`t  $($list.Title): $(Format-Bytes $sm.TotalSize) [versions: $(Format-Bytes $verBytes)] ($($sm.TotalFileCount) files)" "Cyan"
                } catch { BgLog "`t  '$($list.Title)' skipped: $($_.Exception.Message)" "DarkGray" }
            }
        } else {
            try {
                $sm       = Get-PnPFolderStorageMetric -FolderSiteRelativeUrl "/"
                $verBytes = [math]::Max([long]0, $sm.TotalSize - $sm.TotalFileStreamSize)
                $script:_bgResults.Add([PSCustomObject]@{
                    SiteTitle        = $web.Title
                    SiteURL          = $wUrl
                    Library          = "(All Libraries)"
                    LibraryURL       = $wUrl
                    ItemCount        = $sm.TotalFileCount
                    SizeBytes        = $sm.TotalSize
                    VersionSizeBytes = $verBytes
                    SizeMB           = [math]::Round($sm.TotalSize / 1MB, 1)
                    VersionSizeMB    = [math]::Round($verBytes / 1MB, 1)
                    SizeGB           = [math]::Round($sm.TotalSize / 1GB, 3)
                    LastModified     = if ($sm.LastModified) { $sm.LastModified.ToString("dd/MM/yyyy HH:mm") } else { "N/A" }
                })
                BgLog "`t$(Format-Bytes $sm.TotalSize) [versions: $(Format-Bytes $verBytes)] -- $($sm.TotalFileCount) files" "Cyan"
            } catch { BgLog "`tIgnored: $($_.Exception.Message)" "DarkGray" }
        }
        if ($InclSub) {
            $subwebs = Get-PnPSubWeb
            foreach ($sub in $subwebs) {
                BgLog "`tSubsite : $($sub.Title)" "Yellow"
                Collect-WebStorage -WebUrl $sub.Url -PerLib $PerLib -InclSub $InclSub -ClientId $ClientId
                Connect-PnPOnline -Url $WebUrl -Interactive -ClientId $ClientId
            }
        }
    }
    try {
        Import-Module PnP.PowerShell -ErrorAction Stop
        Collect-WebStorage -WebUrl $SiteURL -PerLib $PerLib -InclSub $InclSub -ClientId $ClientId
        $web = Get-PnPWeb
        $Sync.WebTitle = $web.Title
        $Sync.WebURL   = $web.URL
        $Sync.Data     = @($script:_bgResults)
    } catch {
        $Sync.Error = $_.Exception.Message
        BgLog "Error : $($_.Exception.Message)" "Red"
    } finally {
        $Sync.Done = $true
    }
}

# Launches scan for next site in queue; called from button click and timer Done block
function Start-NextStorageScan {
    if ($script:_StorSiteQueue.Count -eq 0) {
        Write-Log "=== All sites processed ===" "Cyan"
        $btnGenStorage.Enabled         = $true
        Stop-ProgressAnim
        return
    }

    $nextUrl  = $script:_StorSiteQueue.Dequeue()
    $siteName = ($nextUrl -split '/')[-1]; if (!$siteName) { $siteName = "root" }
    $ext      = if ($script:_StorFmt -eq "HTML") { ".html" } else { ".csv" }
    $outFile  = Join-Path $script:_StorOutFolder "$siteName-Storage$ext"
    $script:_StorOut = $outFile

    Write-Log ""
    Write-Log "--- Site: $nextUrl" "White"
    Write-Log "    Output: $outFile" "Gray"
    Write-Log ("-" * 52) "DarkGray"

    $script:_StorSyn = [hashtable]::Synchronized(@{
        Queue    = [System.Collections.Generic.Queue[object]]::new()
        Done     = $false
        Error    = $null
        Data     = $null
        WebTitle = ""
        WebURL   = ""
    })

    $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
    $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.Runspace = $rs
    [void]$ps.AddScript($script:_StorBgWork)
    [void]$ps.AddArgument($nextUrl)
    [void]$ps.AddArgument($script:_StorClientId)
    [void]$ps.AddArgument($script:_StorInclSub)
    [void]$ps.AddArgument($script:_StorPerLib)
    [void]$ps.AddArgument($script:_StorDepth)
    [void]$ps.AddArgument($script:_StorSyn)
    $script:_StorRS  = $rs
    $script:_StorPS  = $ps
    $script:_StorHnd = $ps.BeginInvoke()

    $script:_StorTimer = New-Object System.Windows.Forms.Timer
    $script:_StorTimer.Interval = 200
    $script:_StorTimer.Add_Tick({
        while ($script:_StorSyn.Queue.Count -gt 0) {
            $m = $script:_StorSyn.Queue.Dequeue()
            Write-Log $m.Text $m.Color
        }
        if ($script:_StorSyn.Done) {
            $script:_StorTimer.Stop(); $script:_StorTimer.Dispose()
            while ($script:_StorSyn.Queue.Count -gt 0) {
                $m = $script:_StorSyn.Queue.Dequeue(); Write-Log $m.Text $m.Color
            }
            try { [void]$script:_StorPS.EndInvoke($script:_StorHnd) } catch {}
            try { $script:_StorRS.Close(); $script:_StorRS.Dispose() } catch {}

            if ($script:_StorSyn.Error) {
                Write-Log "Failed: $($script:_StorSyn.Error)" "Red"
            } elseif ($script:_StorSyn.Data -and $script:_StorSyn.Data.Count -gt 0) {
                $data = $script:_StorSyn.Data
                Write-Log "Writing output..." "Yellow"
                if ($script:_StorFmt -eq "HTML") {
                    Export-StorageToHTML -Data $data -SiteTitle $script:_StorSyn.WebTitle `
                        -SiteURL $script:_StorSyn.WebURL -OutputPath $script:_StorOut
                } else {
                    $data | Select-Object SiteTitle,SiteURL,Library,ItemCount,SizeMB,VersionSizeMB,SizeGB,LastModified |
                        Export-Csv -Path $script:_StorOut -NoTypeInformation
                }
                Write-Log "Done!  $($data.Count) libs -- $(Format-Bytes (($data | Measure-Object -Property SizeBytes -Sum).Sum))" "Cyan"
                Write-Log "Saved: $script:_StorOut" "White"
                $script:_StorLastOut    = $script:_StorOut
                $btnOpenStorage.Enabled = $true
            } else {
                Write-Log "No data -- check permissions or URL." "Orange"
            }
            # Process next site in queue (if any)
            Start-NextStorageScan
        }
    })
    $script:_StorTimer.Start()
}

$btnGenStorage.Add_Click({
    if (-not (Validate-Inputs)) { return }

    # Build site list: picker selection takes priority, else txtSiteURL
    $siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
        @($script:SelectedSites)
    } else {
        @($txtSiteURL.Text.Trim())
    }
    $siteUrls = @($siteUrls | Where-Object { $_ })

    # Store common scan parameters as script vars (read by Start-NextStorageScan)
    $script:_StorClientId  = $txtClientId.Text.Trim()
    $script:pnpCiD         = $script:_StorClientId
    $script:_StorInclSub   = $chkStorSubsites.Checked
    $script:_StorPerLib    = $chkStorPerLib.Checked
    $script:_StorDepth     = if (-not $chkStorPerLib.Checked) { 0 } elseif ($chkMaxDepth.Checked) { 999 } else { [int]$nudDepth.Value }
    $script:_StorFmt       = if ($radStorHTML.Checked) { "HTML" } else { "CSV" }
    $script:_StorOutFolder = $txtOutput.Text
    $script:_StorLastOut   = $null

    # Build queue
    $script:_StorSiteQueue = [System.Collections.Generic.Queue[string]]::new()
    foreach ($u in $siteUrls) { $script:_StorSiteQueue.Enqueue($u) }

    $btnGenStorage.Enabled  = $false
    $btnOpenStorage.Enabled = $false
    $txtLog.Clear()
    Start-ProgressAnim

    $depthLabel = if ($script:_StorDepth -ge 999) { "Maximum" } elseif ($script:_StorDepth -eq 0) { "N/A" } else { $script:_StorDepth }
    Write-Log "=== STORAGE METRICS ===" "White"
    Write-Log "Sites        : $($siteUrls.Count)" "Gray"
    Write-Log "Format       : $($script:_StorFmt)" "Gray"
    Write-Log "Folder depth : $depthLabel" "Gray"
    Write-Log ("-" * 52) "DarkGray"

    Start-NextStorageScan
})

$btnOpenStorage.Add_Click({
    $f = $script:_StorLastOut
    if ($f -and (Test-Path $f)) { Start-Process $f }
})

# ── Templates ───────────────────────────────────────────────────────────────
$btnOpenTplMgr.Add_Click({
    Show-TemplateManager `
        -DefaultSiteUrl $txtSiteURL.Text.Trim() `
        -ClientId       $txtClientId.Text.Trim() `
        -TenantUrl      $txtTenantUrl.Text.Trim() `
        -Owner          $form
    # Refresh count after dialog closes
    $n = (Load-Templates).Count
    $lblTplCount.Text = "$n template(s) enregistre(s) -- cliquez pour gerer"
})

# ── Recherche de fichiers ───────────────────────────────────────────────────
$btnSearch.Add_Click({
    if (-not (Validate-Inputs)) { return }

    $siteUrl  = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
        $script:SelectedSites[0]
    } else { $txtSiteURL.Text.Trim() }

    if ([string]::IsNullOrWhiteSpace($siteUrl)) {
        [System.Windows.Forms.MessageBox]::Show("Veuillez saisir une URL de site.", "URL manquante", "OK", "Warning")
        return
    }

    # Validate regex before launching
    $regexStr = $txtSrchRegex.Text.Trim()
    if ($regexStr) {
        try { [void][System.Text.RegularExpressions.Regex]::new($regexStr) }
        catch {
            [System.Windows.Forms.MessageBox]::Show(
                "Expression reguliere invalide :`n$($_.Exception.Message)",
                "Regex invalide", "OK", "Error")
            return
        }
    }

    $filters = @{
        Extensions    = @($txtSrchExt.Text.Trim() -split '[,\s]+' | Where-Object { $_ } | ForEach-Object { $_.TrimStart('.').ToLower() })
        Regex         = $regexStr
        CreatedAfter  = if ($chkSrchCrA.Checked)  { $dtpSrchCrA.Value.Date }  else { $null }
        CreatedBefore = if ($chkSrchCrB.Checked)  { $dtpSrchCrB.Value.Date }  else { $null }
        ModifiedAfter = if ($chkSrchModA.Checked) { $dtpSrchModA.Value.Date } else { $null }
        ModifiedBefore= if ($chkSrchModB.Checked) { $dtpSrchModB.Value.Date } else { $null }
        CreatedBy     = $txtSrchCrBy.Text.Trim()
        ModifiedBy    = $txtSrchModBy.Text.Trim()
        Library       = $txtSrchLib.Text.Trim()
        MaxResults    = [int]$nudSrchMax.Value
        Format        = if ($radSrchHTML.Checked) { "HTML" } else { "CSV" }
        OutFolder     = $txtOutput.Text.Trim()
        SiteUrl       = $siteUrl
        ClientId      = $txtClientId.Text.Trim()
    }

    $btnSearch.Enabled     = $false
    $btnOpenSearch.Enabled = $false
    $txtLog.Clear()
    Start-ProgressAnim
    Write-Log "=== RECHERCHE DE FICHIERS ===" "White"
    Write-Log "Site         : $siteUrl" "Gray"
    if ($filters.Extensions.Count -gt 0) { Write-Log "Extensions   : $($filters.Extensions -join ', ')" "Gray" }
    if ($filters.Regex)         { Write-Log "Regex        : $($filters.Regex)" "Gray" }
    if ($filters.CreatedAfter)  { Write-Log "Cree apres   : $($filters.CreatedAfter.ToString('dd/MM/yyyy'))" "Gray" }
    if ($filters.CreatedBefore) { Write-Log "Cree avant   : $($filters.CreatedBefore.ToString('dd/MM/yyyy'))" "Gray" }
    if ($filters.ModifiedAfter) { Write-Log "Modifie apres: $($filters.ModifiedAfter.ToString('dd/MM/yyyy'))" "Gray" }
    if ($filters.ModifiedBefore){ Write-Log "Modifie avant: $($filters.ModifiedBefore.ToString('dd/MM/yyyy'))" "Gray" }
    if ($filters.CreatedBy)     { Write-Log "Cree par     : $($filters.CreatedBy)" "Gray" }
    if ($filters.ModifiedBy)    { Write-Log "Modifie par  : $($filters.ModifiedBy)" "Gray" }
    if ($filters.Library)       { Write-Log "Bibliotheque : $($filters.Library)" "Gray" }
    Write-Log "Max resultats: $($filters.MaxResults)" "Gray"
    Write-Log ("-" * 52) "DarkGray"

    $bgSearch = {
        param($Filters, $Sync)
        function BgLog([string]$m, [string]$c = "LightGreen") {
            $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
        }
        try {
            Import-Module PnP.PowerShell -ErrorAction Stop
            Connect-PnPOnline -Url $Filters.SiteUrl -Interactive -ClientId $Filters.ClientId

            # Build KQL query
            $kqlParts = @("ContentType:Document")
            if ($Filters.Extensions -and $Filters.Extensions.Count -gt 0) {
                $extParts = $Filters.Extensions | ForEach-Object { "FileExtension:$_" }
                $kqlParts += "($($extParts -join ' OR '))"
            }
            if ($Filters.CreatedAfter)   { $kqlParts += "Created>=$($Filters.CreatedAfter.ToString('yyyy-MM-dd'))" }
            if ($Filters.CreatedBefore)  { $kqlParts += "Created<=$($Filters.CreatedBefore.ToString('yyyy-MM-dd'))" }
            if ($Filters.ModifiedAfter)  { $kqlParts += "Write>=$($Filters.ModifiedAfter.ToString('yyyy-MM-dd'))" }
            if ($Filters.ModifiedBefore) { $kqlParts += "Write<=$($Filters.ModifiedBefore.ToString('yyyy-MM-dd'))" }
            if ($Filters.CreatedBy)      { $kqlParts += "Author:""$($Filters.CreatedBy)""" }
            if ($Filters.ModifiedBy)     { $kqlParts += "ModifiedBy:""$($Filters.ModifiedBy)""" }
            if ($Filters.Library) {
                $libPath = "$($Filters.SiteUrl.TrimEnd('/'))/$($Filters.Library.TrimStart('/'))"
                $kqlParts += "Path:""$libPath*"""
            }

            $kql = $kqlParts -join " AND "
            BgLog "Requete KQL : $kql" "Yellow"
            $Sync.KQL = $kql

            $selectProps = @("Title","Path","Author","LastModifiedTime","FileExtension","Created","ModifiedBy","Size")
            $allResults  = [System.Collections.Generic.List[object]]::new()
            $startRow    = 0
            $batchSize   = 500

            do {
                $batch = Submit-PnPSearchQuery -Query $kql `
                    -StartRow $startRow -MaxResults $batchSize `
                    -SelectProperties $selectProps -TrimDuplicates $false
                $hits = @($batch.ResultRows)
                foreach ($h in $hits) { $allResults.Add($h) }
                BgLog "  Lot $([math]::Floor($startRow/$batchSize)+1) : $($hits.Count) resultats (total: $($allResults.Count))" "Cyan"
                $startRow += $batchSize
            } while ($hits.Count -eq $batchSize -and $allResults.Count -lt $Filters.MaxResults)

            # Client-side regex filter on file path/name
            if ($Filters.Regex) {
                $rx = [System.Text.RegularExpressions.Regex]::new(
                    $Filters.Regex,
                    [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
                $allResults = [System.Collections.Generic.List[object]]@(
                    $allResults | Where-Object { $rx.IsMatch($_.Path) }
                )
                BgLog "  Apres filtre regex : $($allResults.Count) resultats" "Cyan"
            }

            # Cap at MaxResults
            if ($allResults.Count -gt $Filters.MaxResults) {
                $allResults = [System.Collections.Generic.List[object]]@(
                    $allResults | Select-Object -First $Filters.MaxResults
                )
            }

            BgLog "$($allResults.Count) fichier(s) trouves" "LightGreen"
            $Sync.Results = @($allResults)
        } catch {
            $Sync.Error = $_.Exception.Message
            BgLog "Erreur : $($_.Exception.Message)" "Red"
        } finally {
            $Sync.Done = $true
        }
    }

    $sync = [hashtable]::Synchronized(@{
        Queue   = [System.Collections.Generic.Queue[object]]::new()
        Done    = $false
        Error   = $null
        Results = $null
        KQL     = ""
    })
    $script:_SrchSync    = $sync
    $script:_SrchFilters = $filters

    $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
    $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.Runspace = $rs
    [void]$ps.AddScript($bgSearch)
    [void]$ps.AddArgument($filters)
    [void]$ps.AddArgument($sync)
    $script:_SrchRS  = $rs
    $script:_SrchPS  = $ps
    $script:_SrchHnd = $ps.BeginInvoke()

    $tmr          = New-Object System.Windows.Forms.Timer
    $tmr.Interval = 250
    $script:_SrchTimer = $tmr
    $tmr.Add_Tick({
        while ($script:_SrchSync.Queue.Count -gt 0) {
            $m = $script:_SrchSync.Queue.Dequeue()
            Write-Log $m.Text $m.Color
        }
        if ($script:_SrchSync.Done) {
            $script:_SrchTimer.Stop(); $script:_SrchTimer.Dispose()
            while ($script:_SrchSync.Queue.Count -gt 0) {
                $m = $script:_SrchSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
            }
            try { [void]$script:_SrchPS.EndInvoke($script:_SrchHnd) } catch {}
            try { $script:_SrchRS.Close(); $script:_SrchRS.Dispose() } catch {}
            $btnSearch.Enabled = $true
            Stop-ProgressAnim

            if ($script:_SrchSync.Error) {
                Write-Log "Echec : $($script:_SrchSync.Error)" "Red"
                return
            }

            $results = $script:_SrchSync.Results
            $kql     = $script:_SrchSync.KQL
            $f       = $script:_SrchFilters

            if (-not $results -or $results.Count -eq 0) {
                Write-Log "Aucun fichier trouve avec ces criteres." "Orange"
                return
            }

            $stamp   = Get-Date -Format "yyyyMMdd_HHmmss"
            $outDir  = $f.OutFolder
            if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }

            if ($f.Format -eq "HTML") {
                $outFile = Join-Path $outDir "FileSearch_$stamp.html"
                $html = Export-SearchResultsToHTML -Results $results -KQL $kql -SiteUrl $f.SiteUrl
                $html | Set-Content -Path $outFile -Encoding UTF8
            } else {
                $outFile = Join-Path $outDir "FileSearch_$stamp.csv"
                $results | ForEach-Object {
                    [PSCustomObject]@{
                        Title           = $_.Title
                        Path            = $_.Path
                        FileExtension   = $_.FileExtension
                        Created         = $_.Created
                        LastModifiedTime= $_.LastModifiedTime
                        Author          = $_.Author
                        ModifiedBy      = $_.ModifiedBy
                        SizeBytes       = $_.Size
                    }
                } | Export-Csv -Path $outFile -NoTypeInformation -Encoding UTF8
            }

            Write-Log "Sauvegarde : $outFile" "White"
            $script:_SrchLastOut   = $outFile
            $btnOpenSearch.Enabled = $true
        }
    })
    $tmr.Start()
})

$btnOpenSearch.Add_Click({
    $f = $script:_SrchLastOut
    if ($f -and (Test-Path $f)) { Start-Process $f }
})

# ── Scan de doublons ────────────────────────────────────────────────────────
$btnScanDupes.Add_Click({
    if (-not (Validate-Inputs)) { return }

    $siteUrl = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
        $script:SelectedSites[0]
    } else { $txtSiteURL.Text.Trim() }
    if ([string]::IsNullOrWhiteSpace($siteUrl)) {
        [System.Windows.Forms.MessageBox]::Show("Veuillez saisir une URL de site.", "URL manquante", "OK", "Warning")
        return
    }

    $dupFilters = @{
        Mode         = if ($radDupFolders.Checked) { "Folders" } else { "Files" }
        MatchSize    = $chkDupSize.Checked
        MatchCreated = $chkDupCreated.Checked
        MatchMod     = $chkDupModified.Checked
        MatchSubDir  = $chkDupSubCount.Checked
        MatchFiles   = $chkDupFileCount.Checked
        IncludeSubs  = $chkDupSubsites.Checked
        Library      = $txtDupLib.Text.Trim()
        Format       = if ($radDupHTML.Checked) { "HTML" } else { "CSV" }
        OutFolder    = $txtOutput.Text.Trim()
        SiteUrl      = $siteUrl
        ClientId     = $txtClientId.Text.Trim()
    }

    $btnScanDupes.Enabled  = $false
    $btnOpenDupes.Enabled  = $false
    $txtLog.Clear()
    Start-ProgressAnim
    Write-Log "=== SCAN DE DOUBLONS ===" "White"
    Write-Log "Mode         : $($dupFilters.Mode)" "Gray"
    Write-Log "Site         : $siteUrl" "Gray"
    Write-Log "Criteres     : Nom (toujours)$(if($dupFilters.MatchSize){', Taille'})$(if($dupFilters.MatchCreated){', Cree le'})$(if($dupFilters.MatchMod){', Modifie le'})$(if($dupFilters.MatchSubDir){', Nb sous-doss.'})$(if($dupFilters.MatchFiles){', Nb fichiers'})" "Gray"
    Write-Log ("-" * 52) "DarkGray"

    $bgDupScan = {
        param($Filters, $Sync)
        function BgLog([string]$m, [string]$c = "LightGreen") {
            $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
        }
        function MakeKey($name, $item, $f) {
            $parts = @($name.ToLower())
            if ($f.MatchSize    -and $null -ne $item.SizeBytes)  { $parts += [string]$item.SizeBytes }
            if ($f.MatchCreated -and $null -ne $item.CreatedDay) { $parts += $item.CreatedDay }
            if ($f.MatchMod     -and $null -ne $item.ModifiedDay){ $parts += $item.ModifiedDay }
            if ($f.MatchSubDir  -and $null -ne $item.FolderCount){ $parts += [string]$item.FolderCount }
            if ($f.MatchFiles   -and $null -ne $item.FileCount)  { $parts += [string]$item.FileCount }
            return $parts -join "|"
        }
        try {
            Import-Module PnP.PowerShell -ErrorAction Stop
            Connect-PnPOnline -Url $Filters.SiteUrl -Interactive -ClientId $Filters.ClientId

            $allItems = [System.Collections.Generic.List[object]]::new()

            if ($Filters.Mode -eq "Files") {
                # ── Files: use Search API ──────────────────────────────────
                $kql = "ContentType:Document"
                if ($Filters.Library) {
                    $kql += " AND Path:""$($Filters.SiteUrl.TrimEnd('/'))/$($Filters.Library.TrimStart('/'))*"""
                }
                BgLog "Requete KQL : $kql" "Yellow"
                $startRow = 0; $batchSize = 500
                do {
                    $batch = Submit-PnPSearchQuery -Query $kql `
                        -StartRow $startRow -MaxResults $batchSize `
                        -SelectProperties @("Title","Path","Author","LastModifiedTime","FileExtension","Created","Size") `
                        -TrimDuplicates $false
                    $hits = @($batch.ResultRows)
                    foreach ($h in $hits) {
                        # Ignore version history entries (SharePoint stores them under /_vti_history/)
                        if ($h.Path -match '/_vti_history/') { continue }
                        $fname = [System.IO.Path]::GetFileName($h.Path)
                        try { $crDay = ([DateTime]$h.Created).Date.ToString('yyyy-MM-dd') } catch { $crDay = "" }
                        try { $modDay = ([DateTime]$h.LastModifiedTime).Date.ToString('yyyy-MM-dd') } catch { $modDay = "" }
                        $sizeB = [long]($h.Size -replace '[^0-9]','0' -replace '^$','0')
                        $allItems.Add([PSCustomObject]@{
                            Name        = $fname
                            Path        = $h.Path
                            Library     = ""
                            SizeBytes   = $sizeB
                            Created     = $h.Created
                            Modified    = $h.LastModifiedTime
                            CreatedDay  = $crDay
                            ModifiedDay = $modDay
                        })
                    }
                    BgLog "  $($hits.Count) fichiers recuperes (total: $($allItems.Count))" "Cyan"
                    $startRow += $batchSize
                } while ($hits.Count -eq $batchSize)

            } else {
                # ── Folders: use Get-PnPListItem ───────────────────────────
                $webUrls = @($Filters.SiteUrl)
                if ($Filters.IncludeSubs) {
                    $subs = Get-PnPSubWeb -Recurse -ErrorAction SilentlyContinue
                    if ($subs) { $webUrls += @($subs | Select-Object -ExpandProperty Url) }
                }

                foreach ($webUrl in $webUrls) {
                    BgLog "Scan web : $webUrl" "Yellow"
                    Connect-PnPOnline -Url $webUrl -Interactive -ClientId $Filters.ClientId
                    $lists = Get-PnPList | Where-Object {
                        !$_.Hidden -and $_.BaseType -eq "DocumentLibrary" -and
                        (-not $Filters.Library -or $_.Title -like "*$($Filters.Library)*")
                    }
                    foreach ($list in $lists) {
                        BgLog "  Bibliotheque : $($list.Title)" "Cyan"
                        try {
                            $folderItems = Get-PnPListItem -List $list -PageSize 2000 -ErrorAction SilentlyContinue |
                                Where-Object { $_.FileSystemObjectType -eq "Folder" }
                            foreach ($fi in $folderItems) {
                                $fv = $fi.FieldValues
                                $fname = $fv.FileLeafRef
                                try { $crDay = ([DateTime]$fv.Created).Date.ToString('yyyy-MM-dd') } catch { $crDay = "" }
                                try { $modDay = ([DateTime]$fv.Modified).Date.ToString('yyyy-MM-dd') } catch { $modDay = "" }
                                $subCount  = [int]($fv.FolderChildCount)
                                $fileCount = [int]($fv.ItemChildCount) - $subCount
                                if ($fileCount -lt 0) { $fileCount = 0 }
                                $allItems.Add([PSCustomObject]@{
                                    Name        = $fname
                                    Path        = "$($Filters.SiteUrl.TrimEnd('/'))/$($fv.FileRef.TrimStart('/'))"
                                    Library     = $list.Title
                                    SizeBytes   = $null
                                    Created     = $fv.Created
                                    Modified    = $fv.Modified
                                    CreatedDay  = $crDay
                                    ModifiedDay = $modDay
                                    FolderCount = $subCount
                                    FileCount   = $fileCount
                                })
                            }
                            BgLog "    $($folderItems.Count) dossier(s)" "Cyan"
                        } catch { BgLog "    Ignore : $($_.Exception.Message)" "DarkGray" }
                    }
                }
            }

            BgLog "$($allItems.Count) element(s) collecte(s), recherche des doublons..." "Yellow"

            # Group by computed key and keep only groups with ≥ 2
            $grouped = $allItems | Group-Object { MakeKey $_.Name $_ $Filters } |
                Where-Object { $_.Count -ge 2 } |
                ForEach-Object {
                    [PSCustomObject]@{
                        Key   = $_.Name
                        Name  = $_.Group[0].Name
                        Items = @($_.Group)
                    }
                }

            BgLog "$($grouped.Count) groupe(s) de doublons trouve(s)" "LightGreen"
            $Sync.Groups = @($grouped)

        } catch {
            $Sync.Error = $_.Exception.Message
            BgLog "Erreur : $($_.Exception.Message)" "Red"
        } finally { $Sync.Done = $true }
    }

    $dupSync = [hashtable]::Synchronized(@{
        Queue  = [System.Collections.Generic.Queue[object]]::new()
        Done   = $false; Error = $null; Groups = $null
    })
    $script:_DupSync    = $dupSync
    $script:_DupFilters = $dupFilters

    $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
    $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.Runspace = $rs
    [void]$ps.AddScript($bgDupScan)
    [void]$ps.AddArgument($dupFilters)
    [void]$ps.AddArgument($dupSync)
    $script:_DupRS  = $rs
    $script:_DupPS  = $ps
    $script:_DupHnd = $ps.BeginInvoke()

    $tmr           = New-Object System.Windows.Forms.Timer
    $tmr.Interval  = 250
    $script:_DupTimer = $tmr
    $tmr.Add_Tick({
        while ($script:_DupSync.Queue.Count -gt 0) {
            $m = $script:_DupSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
        }
        if ($script:_DupSync.Done) {
            $script:_DupTimer.Stop(); $script:_DupTimer.Dispose()
            while ($script:_DupSync.Queue.Count -gt 0) {
                $m = $script:_DupSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
            }
            try { [void]$script:_DupPS.EndInvoke($script:_DupHnd) } catch {}
            try { $script:_DupRS.Close(); $script:_DupRS.Dispose() } catch {}
            $btnScanDupes.Enabled = $true
            Stop-ProgressAnim

            if ($script:_DupSync.Error) {
                Write-Log "Echec : $($script:_DupSync.Error)" "Red"
                return
            }

            $groups = $script:_DupSync.Groups
            if (-not $groups -or $groups.Count -eq 0) {
                Write-Log "Aucun doublon detecte avec ces criteres." "Orange"
                return
            }

            $f      = $script:_DupFilters
            $stamp  = Get-Date -Format "yyyyMMdd_HHmmss"
            $outDir = $f.OutFolder
            if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }

            if ($f.Format -eq "HTML") {
                $outFile = Join-Path $outDir "Duplicates_$($f.Mode)_$stamp.html"
                $html = Export-DuplicatesToHTML -Groups $groups -Mode $f.Mode -SiteUrl $f.SiteUrl
                $html | Set-Content -Path $outFile -Encoding UTF8
            } else {
                $outFile = Join-Path $outDir "Duplicates_$($f.Mode)_$stamp.csv"
                $groups | ForEach-Object {
                    $grp = $_
                    $grp.Items | ForEach-Object {
                        [PSCustomObject]@{
                            DuplicateGroup  = $grp.Name
                            Name            = $_.Name
                            Path            = $_.Path
                            Library         = $_.Library
                            SizeBytes       = $_.SizeBytes
                            Created         = $_.Created
                            Modified        = $_.Modified
                            FolderCount     = $_.FolderCount
                            FileCount       = $_.FileCount
                        }
                    }
                } | Export-Csv -Path $outFile -NoTypeInformation -Encoding UTF8
            }

            Write-Log "Sauvegarde : $outFile" "White"
            $script:_DupLastOut  = $outFile
            $btnOpenDupes.Enabled = $true
        }
    })
    $tmr.Start()
})

$btnOpenDupes.Add_Click({
    $f = $script:_DupLastOut
    if ($f -and (Test-Path $f)) { Start-Process $f }
})

# ── Transfer ──────────────────────────────────────────────────────────────────

# ── CSV Import for bulk transfers ─────────────────────────────────────────────
$script:_XferCsvEntries = [System.Collections.Generic.List[object]]::new()

$btnXferCsvImport.Add_Click({
    $ofd = New-Object System.Windows.Forms.OpenFileDialog
    $ofd.Filter = "CSV Files (*.csv)|*.csv"
    $ofd.Title  = "Import Transfer CSV"
    if ($ofd.ShowDialog() -ne "OK") { return }

    $script:_XferCsvEntries.Clear()
    try {
        $rows = Import-Csv -Path $ofd.FileName -Delimiter ';' -Encoding UTF8
        foreach ($r in $rows) {
            $src = ($r.PSObject.Properties | Where-Object { $_.Name -match '^SourceSite$' }).Value
            $dst = ($r.PSObject.Properties | Where-Object { $_.Name -match '^DestSite$' }).Value
            $sl  = ($r.PSObject.Properties | Where-Object { $_.Name -match '^SourceLibrary$' }).Value
            $dl  = ($r.PSObject.Properties | Where-Object { $_.Name -match '^DestLibrary$' }).Value
            if ($src -and $sl) {
                $script:_XferCsvEntries.Add(@{
                    SrcSite = $src.Trim()
                    DstSite = if ($dst) { $dst.Trim() } else { $src.Trim() }
                    SrcLib  = $sl.Trim()
                    DstLib  = if ($dl) { $dl.Trim() } else { $sl.Trim() }
                })
            }
        }
        $n = $script:_XferCsvEntries.Count
        $lblXferCsvInfo.Text = (T "lbl.xfer.csv.info") -f $n
        $btnXferCsvClear.Visible = $true
        $txtXferSrcSite.Enabled = $false; $txtXferSrcLib.Enabled = $false
        $txtXferDstSite.Enabled = $false; $txtXferDstLib.Enabled = $false
        Write-Log "CSV loaded: $n transfer(s)" "White"
    } catch {
        Write-Log "CSV import error: $($_.Exception.Message)" "Red"
    }
})

$btnXferCsvClear.Add_Click({
    $script:_XferCsvEntries.Clear()
    $lblXferCsvInfo.Text = ""
    $btnXferCsvClear.Visible = $false
    $txtXferSrcSite.Enabled = $true; $txtXferSrcLib.Enabled = $true
    $txtXferDstSite.Enabled = $true; $txtXferDstLib.Enabled = $true
})

# ── Helper: build transfer jobs list (from CSV or manual fields) ──────────────
function Get-XferJobs {
    if ($script:_XferCsvEntries.Count -gt 0) {
        return @($script:_XferCsvEntries)
    }
    $srcSite = $txtXferSrcSite.Text.Trim()
    $dstSite = $txtXferDstSite.Text.Trim()
    $srcLib  = $txtXferSrcLib.Text.Trim()
    $dstLib  = $txtXferDstLib.Text.Trim()
    if (-not $srcSite) { Write-Log "URL du site source requis." "Red"; return @() }
    if (-not $dstSite) { Write-Log "URL du site destination requis." "Red"; return @() }
    if (-not $srcLib)  { Write-Log "Bibliotheque source requise." "Red"; return @() }
    if (-not $dstLib)  { $dstLib = $srcLib }
    if ($srcSite -eq $dstSite -and $srcLib -eq $dstLib) {
        Write-Log "Source et destination identiques." "Red"; return @()
    }
    return @(@{ SrcSite = $srcSite; DstSite = $dstSite; SrcLib = $srcLib; DstLib = $dstLib })
}

# ── Helper: export transfer report ────────────────────────────────────────────
function Export-XferReport([System.Collections.Generic.List[object]]$Results, [string]$OutDir, [string]$Prefix, [bool]$AsHtml) {
    if ($Results.Count -eq 0) { return $null }
    if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
    $stamp = Get-Date -Format "yyyyMMdd_HHmmss"

    if (-not $AsHtml) {
        $f = Join-Path $OutDir "${Prefix}_$stamp.csv"
        $Results | Export-Csv -Path $f -NoTypeInformation -Encoding UTF8
        return $f
    }

    # HTML report
    $f = Join-Path $OutDir "${Prefix}_$stamp.html"
    $okN   = @($Results | Where-Object { $_.Status -eq "OK" }).Count
    $errN  = @($Results | Where-Object { $_.Status -eq "ERROR" }).Count
    $skipN = @($Results | Where-Object { $_.Status -eq "SKIPPED" }).Count
    $missN = @($Results | Where-Object { $_.Status -eq "MISSING" }).Count
    $mmN   = @($Results | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count
    $exN   = @($Results | Where-Object { $_.Status -eq "EXTRA" }).Count

    $rows = ""
    foreach ($r in $Results) {
        $color = switch ($r.Status) {
            "OK"            { "#e6f4ea" }
            "ERROR"         { "#fce8e6" }
            "SKIPPED"       { "#fff3cd" }
            "MISSING"       { "#fce8e6" }
            "SIZE_MISMATCH" { "#fff3cd" }
            "EXTRA"         { "#e8f0fe" }
            default         { "#ffffff" }
        }
        $srcSz = if ($null -ne $r.SourceSize) { '{0:N0}' -f $r.SourceSize } else { "-" }
        $dstSz = if ($null -ne $r.DestSize)   { '{0:N0}' -f $r.DestSize }   else { "-" }
        $msg   = if ($r.Message) { [System.Web.HttpUtility]::HtmlEncode($r.Message) } else { "" }
        $rows += "<tr style='background:$color'><td>$([System.Web.HttpUtility]::HtmlEncode($r.SourceSite))</td><td>$([System.Web.HttpUtility]::HtmlEncode($r.File))</td><td><b>$($r.Status)</b></td><td style='text-align:right'>$srcSz</td><td style='text-align:right'>$dstSz</td><td>$msg</td></tr>`n"
    }

    $html = @"
<!DOCTYPE html><html><head><meta charset='utf-8'><title>Transfer Report</title>
<style>body{font-family:'Segoe UI',sans-serif;margin:20px;background:#f5f5f5}
h1{color:#1e3a5f}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:6px 10px;font-size:13px}
th{background:#1e3a5f;color:#fff;position:sticky;top:0}.summary{display:flex;gap:15px;margin:12px 0}
.badge{padding:4px 12px;border-radius:4px;font-weight:bold;font-size:13px}
.ok{background:#e6f4ea;color:#1e7e34}.err{background:#fce8e6;color:#c62828}
.warn{background:#fff3cd;color:#856404}.info{background:#e8f0fe;color:#1565c0}
input[type=text]{padding:6px;margin:8px 0;width:300px;border:1px solid #ccc;border-radius:4px}</style>
<script>function filterTable(){var v=document.getElementById('q').value.toLowerCase();var rows=document.querySelectorAll('tbody tr');
rows.forEach(function(r){r.style.display=r.textContent.toLowerCase().indexOf(v)>-1?'':'none'})}</script>
</head><body><h1>Transfer Report</h1>
<div class='summary'>
<span class='badge ok'>OK: $okN</span><span class='badge err'>Error: $errN</span>
<span class='badge warn'>Skipped: $skipN / Mismatch: $mmN</span><span class='badge err'>Missing: $missN</span>
<span class='badge info'>Extra: $exN</span></div>
<input type='text' id='q' onkeyup='filterTable()' placeholder='Filter...'>
<table><thead><tr><th>Site</th><th>File</th><th>Status</th><th>Source Size</th><th>Dest Size</th><th>Message</th></tr></thead>
<tbody>$rows</tbody></table></body></html>
"@
    $html | Set-Content -Path $f -Encoding UTF8
    return $f
}

# ── Transfer Start ────────────────────────────────────────────────────────────

$btnXferStart.Add_Click({
    $clientId = $txtClientId.Text.Trim()
    if (-not $clientId) { Write-Log "Client ID requis." "Red"; return }

    $jobs = Get-XferJobs
    if ($jobs.Count -eq 0) { return }

    $recursive    = $chkXferRecursive.Checked
    $overwrite    = $chkXferOverwrite.Checked
    $createFolder = $chkXferCreateFolders.Checked
    $outDir       = $txtOutput.Text.Trim()
    if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
    $asHtml       = $radXferHtml.Checked

    $params = @{
        ClientId      = $clientId
        Jobs          = @($jobs)
        Recursive     = $recursive
        Overwrite     = $overwrite
        CreateFolders = $createFolder
        OutFolder     = $outDir
        AsHtml        = $asHtml
    }

    $btnXferStart.Enabled  = $false
    $btnXferVerify.Enabled = $false
    $btnXferOpen.Enabled   = $false
    $txtLog.Clear()
    Start-ProgressAnim
    Write-Log "=== TRANSFER ($($jobs.Count) job(s)) ===" "White"
    foreach ($j in $jobs) {
        Write-Log "  $($j.SrcSite)/$($j.SrcLib) -> $($j.DstSite)/$($j.DstLib)" "Gray"
    }
    Write-Log "Recursive: $recursive  Overwrite: $overwrite  Create folders: $createFolder" "Gray"
    Write-Log ("-" * 52) "DarkGray"

    $bgTransfer = {
        param($Params, $Sync)
        function BgLog([string]$m, [string]$c = "LightGreen") {
            $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
        }

        function Get-AllSPFiles([string]$BasePath, [bool]$Recurse, [string]$Rel = "") {
            $files = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType File -ErrorAction SilentlyContinue)
            foreach ($f in $files) {
                if ($f.ServerRelativeUrl -match '/_vti_history/') { continue }
                [PSCustomObject]@{
                    Name              = $f.Name
                    ServerRelativeUrl = $f.ServerRelativeUrl
                    Length            = $f.Length
                    RelativePath      = "$Rel$($f.Name)"
                    RelativeFolder    = $Rel
                }
            }
            if ($Recurse) {
                $folders = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType Folder -ErrorAction SilentlyContinue)
                foreach ($d in $folders) {
                    if ($d.Name -in @("Forms", "_vti_cnf", "_vti_history")) { continue }
                    Get-AllSPFiles "$BasePath/$($d.Name)" $Recurse "$Rel$($d.Name)/"
                }
            }
        }

        try {
            Import-Module PnP.PowerShell -ErrorAction Stop
            $report = [System.Collections.Generic.List[object]]::new()
            $totalOk = 0; $totalErr = 0; $totalSkip = 0

            foreach ($job in $Params.Jobs) {
                BgLog "--- $($job.SrcSite) / $($job.SrcLib) -> $($job.DstSite) / $($job.DstLib) ---" "White"

                # Enumerate source
                BgLog "Connecting to source..." "Cyan"
                Connect-PnPOnline -Url $job.SrcSite -Interactive -ClientId $Params.ClientId
                $srcFiles = @(Get-AllSPFiles $job.SrcLib $Params.Recursive)
                BgLog "  $($srcFiles.Count) file(s) found" "LightGreen"

                if ($srcFiles.Count -eq 0) {
                    BgLog "  No files to transfer." "DarkOrange"
                    continue
                }

                # Download to temp
                $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "SPToolbox_Xfer_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$([guid]::NewGuid().ToString('N').Substring(0,6))"
                New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null

                $idx = 0
                foreach ($f in $srcFiles) {
                    $idx++
                    $localDir = Join-Path $tempRoot $f.RelativeFolder
                    if (-not (Test-Path $localDir)) { New-Item -ItemType Directory -Path $localDir -Force | Out-Null }
                    try {
                        Get-PnPFile -Url $f.ServerRelativeUrl -Path $localDir -FileName $f.Name -AsFile -Force
                    } catch {
                        BgLog "  ERROR downloading $($f.RelativePath): $($_.Exception.Message)" "Red"
                        $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="ERROR"; SourceSize=$f.Length; DestSize=$null; Message="Download: $($_.Exception.Message)" })
                        $totalErr++
                        continue
                    }
                }

                # Upload to destination
                BgLog "Connecting to destination..." "Cyan"
                Connect-PnPOnline -Url $job.DstSite -Interactive -ClientId $Params.ClientId

                $idx = 0
                foreach ($f in $srcFiles) {
                    $idx++
                    $dstFolder = if ($f.RelativeFolder) {
                        "$($job.DstLib.TrimEnd('/'))/$($f.RelativeFolder.TrimEnd('/'))"
                    } else { $job.DstLib }

                    # Check if dest folder exists / create if needed
                    if ($Params.CreateFolders) {
                        try { Resolve-PnPFolder -SiteRelativePath $dstFolder -ErrorAction Stop | Out-Null } catch {
                            BgLog "  ERROR creating folder $dstFolder : $($_.Exception.Message)" "Red"
                            $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="ERROR"; SourceSize=$f.Length; DestSize=$null; Message="Folder creation: $($_.Exception.Message)" })
                            $totalErr++
                            continue
                        }
                    }

                    $localFile = Join-Path (Join-Path $tempRoot $f.RelativeFolder) $f.Name
                    if (-not (Test-Path $localFile)) { continue }

                    # Check for existing file if not overwriting
                    if (-not $Params.Overwrite) {
                        try {
                            $existing = Get-PnPFile -Url "$dstFolder/$($f.Name)" -ErrorAction SilentlyContinue
                            if ($existing) {
                                BgLog "  [$idx/$($srcFiles.Count)] SKIPPED (exists): $($f.RelativePath)" "DarkOrange"
                                $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="SKIPPED"; SourceSize=$f.Length; DestSize=$null; Message="File already exists" })
                                $totalSkip++
                                continue
                            }
                        } catch {}
                    }

                    try {
                        Add-PnPFile -Path $localFile -Folder $dstFolder -ErrorAction Stop | Out-Null
                        BgLog "  [$idx/$($srcFiles.Count)] OK: $($f.RelativePath)" "LightGreen"
                        $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="OK"; SourceSize=$f.Length; DestSize=$f.Length; Message="" })
                        $totalOk++
                    } catch {
                        BgLog "  [$idx/$($srcFiles.Count)] ERROR: $($f.RelativePath) - $($_.Exception.Message)" "Red"
                        $report.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$f.RelativePath; Status="ERROR"; SourceSize=$f.Length; DestSize=$null; Message=$_.Exception.Message })
                        $totalErr++
                    }
                }

                # Cleanup temp
                if ($tempRoot -and (Test-Path $tempRoot)) {
                    Remove-Item -Path $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
                }
            }

            $Sync.Report     = @($report)
            $Sync.TotalOk    = $totalOk
            $Sync.TotalErr   = $totalErr
            $Sync.TotalSkip  = $totalSkip
        } catch {
            $Sync.Error = $_.Exception.Message
            BgLog "Erreur : $($_.Exception.Message)" "Red"
        } finally {
            $Sync.Done = $true
        }
    }

    $sync = [hashtable]::Synchronized(@{
        Queue     = [System.Collections.Generic.Queue[object]]::new()
        Done      = $false
        Error     = $null
        Report    = $null
        TotalOk   = 0
        TotalErr  = 0
        TotalSkip = 0
    })
    $script:_XferSync   = $sync
    $script:_XferParams = $params

    $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
    $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.Runspace = $rs
    [void]$ps.AddScript($bgTransfer)
    [void]$ps.AddArgument($params)
    [void]$ps.AddArgument($sync)
    $script:_XferRS  = $rs
    $script:_XferPS  = $ps
    $script:_XferHnd = $ps.BeginInvoke()

    $tmr          = New-Object System.Windows.Forms.Timer
    $tmr.Interval = 250
    $script:_XferTimer = $tmr
    $tmr.Add_Tick({
        while ($script:_XferSync.Queue.Count -gt 0) {
            $m = $script:_XferSync.Queue.Dequeue()
            Write-Log $m.Text $m.Color
        }
        if ($script:_XferSync.Done) {
            $script:_XferTimer.Stop(); $script:_XferTimer.Dispose()
            while ($script:_XferSync.Queue.Count -gt 0) {
                $m = $script:_XferSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
            }
            try { [void]$script:_XferPS.EndInvoke($script:_XferHnd) } catch {}
            try { $script:_XferRS.Close(); $script:_XferRS.Dispose() } catch {}
            $btnXferStart.Enabled  = $true
            $btnXferVerify.Enabled = $true
            Stop-ProgressAnim

            if ($script:_XferSync.Error) {
                Write-Log "Echec du transfert : $($script:_XferSync.Error)" "Red"
                return
            }

            $ok = $script:_XferSync.TotalOk; $er = $script:_XferSync.TotalErr; $sk = $script:_XferSync.TotalSkip
            Write-Log "=== TRANSFER COMPLETE: $ok OK, $er error(s), $sk skipped ===" "White"

            # Generate report
            $report = $script:_XferSync.Report
            if ($report -and $report.Count -gt 0) {
                $p = $script:_XferParams
                $rptFile = Export-XferReport ([System.Collections.Generic.List[object]]$report) $p.OutFolder "Transfer" $p.AsHtml
                if ($rptFile) {
                    Write-Log "Report: $rptFile" "White"
                    $script:_XferLastReport = $rptFile
                    $btnXferOpen.Enabled = $true
                }
            }
        }
    })
    $tmr.Start()
})

# ── Verify ────────────────────────────────────────────────────────────────────

$btnXferVerify.Add_Click({
    $clientId = $txtClientId.Text.Trim()
    if (-not $clientId) { Write-Log "Client ID requis." "Red"; return }

    $jobs = Get-XferJobs
    if ($jobs.Count -eq 0) { return }

    $recursive = $chkXferRecursive.Checked
    $outDir    = $txtOutput.Text.Trim()
    if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
    $asHtml    = $radXferHtml.Checked

    $params = @{
        ClientId  = $clientId
        Jobs      = @($jobs)
        Recursive = $recursive
        OutFolder = $outDir
        AsHtml    = $asHtml
    }

    $btnXferStart.Enabled  = $false
    $btnXferVerify.Enabled = $false
    $btnXferOpen.Enabled   = $false
    $txtLog.Clear()
    Start-ProgressAnim
    Write-Log "=== VERIFICATION ($($jobs.Count) job(s)) ===" "White"
    Write-Log ("-" * 52) "DarkGray"

    $bgVerify = {
        param($Params, $Sync)
        function BgLog([string]$m, [string]$c = "LightGreen") {
            $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
        }

        function Get-AllSPFiles([string]$BasePath, [bool]$Recurse, [string]$Rel = "") {
            $files = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType File -ErrorAction SilentlyContinue)
            foreach ($f in $files) {
                if ($f.ServerRelativeUrl -match '/_vti_history/') { continue }
                [PSCustomObject]@{
                    Name         = $f.Name
                    Length       = $f.Length
                    RelativePath = "$Rel$($f.Name)"
                }
            }
            if ($Recurse) {
                $folders = @(Get-PnPFolderItem -FolderSiteRelativeUrl $BasePath -ItemType Folder -ErrorAction SilentlyContinue)
                foreach ($d in $folders) {
                    if ($d.Name -in @("Forms", "_vti_cnf", "_vti_history")) { continue }
                    Get-AllSPFiles "$BasePath/$($d.Name)" $Recurse "$Rel$($d.Name)/"
                }
            }
        }

        try {
            Import-Module PnP.PowerShell -ErrorAction Stop
            $allResults = [System.Collections.Generic.List[object]]::new()

            foreach ($job in $Params.Jobs) {
                BgLog "--- Verifying $($job.SrcSite)/$($job.SrcLib) vs $($job.DstSite)/$($job.DstLib) ---" "White"

                BgLog "Connecting to source..." "Cyan"
                Connect-PnPOnline -Url $job.SrcSite -Interactive -ClientId $Params.ClientId
                $srcFiles = @(Get-AllSPFiles $job.SrcLib $Params.Recursive)
                BgLog "  $($srcFiles.Count) source file(s)" "LightGreen"
                $srcMap = @{}; foreach ($f in $srcFiles) { $srcMap[$f.RelativePath] = $f }

                BgLog "Connecting to destination..." "Cyan"
                Connect-PnPOnline -Url $job.DstSite -Interactive -ClientId $Params.ClientId
                $dstFiles = @(Get-AllSPFiles $job.DstLib $Params.Recursive)
                BgLog "  $($dstFiles.Count) destination file(s)" "LightGreen"
                $dstMap = @{}; foreach ($f in $dstFiles) { $dstMap[$f.RelativePath] = $f }

                foreach ($key in $srcMap.Keys) {
                    $src = $srcMap[$key]
                    if ($dstMap.ContainsKey($key)) {
                        $dst = $dstMap[$key]
                        $st = if ([long]$src.Length -eq [long]$dst.Length) { "OK" } else { "SIZE_MISMATCH" }
                        $allResults.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$key; Status=$st; SourceSize=[long]$src.Length; DestSize=[long]$dst.Length; Message="" })
                    } else {
                        $allResults.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$key; Status="MISSING"; SourceSize=[long]$src.Length; DestSize=$null; Message="" })
                    }
                }
                foreach ($key in $dstMap.Keys) {
                    if (-not $srcMap.ContainsKey($key)) {
                        $dst = $dstMap[$key]
                        $allResults.Add([PSCustomObject]@{ SourceSite=$job.SrcSite; DestSite=$job.DstSite; File=$key; Status="EXTRA"; SourceSize=$null; DestSize=[long]$dst.Length; Message="" })
                    }
                }

                $okN   = @($allResults | Where-Object { $_.Status -eq "OK" }).Count
                $missN = @($allResults | Where-Object { $_.Status -eq "MISSING" }).Count
                $mmN   = @($allResults | Where-Object { $_.Status -eq "SIZE_MISMATCH" }).Count
                $exN   = @($allResults | Where-Object { $_.Status -eq "EXTRA" }).Count
                BgLog "  Results: $okN OK, $missN missing, $mmN size mismatch, $exN extra" "White"
            }

            $Sync.VerifyResults = @($allResults)
        } catch {
            $Sync.Error = $_.Exception.Message
            BgLog "Erreur : $($_.Exception.Message)" "Red"
        } finally {
            $Sync.Done = $true
        }
    }

    $sync = [hashtable]::Synchronized(@{
        Queue         = [System.Collections.Generic.Queue[object]]::new()
        Done          = $false
        Error         = $null
        VerifyResults = $null
    })
    $script:_VerSync   = $sync
    $script:_VerParams = $params

    $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
    $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.Runspace = $rs
    [void]$ps.AddScript($bgVerify)
    [void]$ps.AddArgument($params)
    [void]$ps.AddArgument($sync)
    $script:_VerRS  = $rs
    $script:_VerPS  = $ps
    $script:_VerHnd = $ps.BeginInvoke()

    $tmr          = New-Object System.Windows.Forms.Timer
    $tmr.Interval = 250
    $script:_VerTimer = $tmr
    $tmr.Add_Tick({
        while ($script:_VerSync.Queue.Count -gt 0) {
            $m = $script:_VerSync.Queue.Dequeue()
            Write-Log $m.Text $m.Color
        }
        if ($script:_VerSync.Done) {
            $script:_VerTimer.Stop(); $script:_VerTimer.Dispose()
            while ($script:_VerSync.Queue.Count -gt 0) {
                $m = $script:_VerSync.Queue.Dequeue(); Write-Log $m.Text $m.Color
            }
            try { [void]$script:_VerPS.EndInvoke($script:_VerHnd) } catch {}
            try { $script:_VerRS.Close(); $script:_VerRS.Dispose() } catch {}
            $btnXferStart.Enabled  = $true
            $btnXferVerify.Enabled = $true
            Stop-ProgressAnim

            if ($script:_VerSync.Error) {
                Write-Log "Echec de la verification : $($script:_VerSync.Error)" "Red"
                return
            }

            $results = $script:_VerSync.VerifyResults
            if (-not $results -or $results.Count -eq 0) {
                Write-Log "Aucun fichier a comparer." "Orange"
                return
            }

            $p = $script:_VerParams
            $rptFile = Export-XferReport ([System.Collections.Generic.List[object]]$results) $p.OutFolder "TransferVerify" $p.AsHtml
            if ($rptFile) {
                Write-Log "Report: $rptFile" "White"
                $script:_XferLastReport = $rptFile
                $btnXferOpen.Enabled = $true
            }
        }
    })
    $tmr.Start()
})

# ── Open Transfer Report ──────────────────────────────────────────────────────

$btnXferOpen.Add_Click({
    $f = $script:_XferLastReport
    if ($f -and (Test-Path $f)) { Start-Process $f }
})

# ── Bulk Create ───────────────────────────────────────────────────────────────

# Helper: add a site entry hashtable to the ListView
function Add-BulkListItem([hashtable]$entry) {
    $lvi = New-Object System.Windows.Forms.ListViewItem($entry.Name)
    $lvi.SubItems.Add($entry.Alias)   | Out-Null
    $lvi.SubItems.Add($entry.Type)    | Out-Null
    $lvi.SubItems.Add($entry.Template)| Out-Null
    $lvi.SubItems.Add($entry.Owners)  | Out-Null
    $lvi.SubItems.Add($entry.Members) | Out-Null
    $lvi.Tag = $entry
    $lvBulk.Items.Add($lvi) | Out-Null
}

# Double-click to edit
$lvBulk.Add_DoubleClick({
    if ($lvBulk.SelectedItems.Count -eq 0) { return }
    $sel = $lvBulk.SelectedItems[0]
    $edited = Show-BulkSiteDialog -Owner $form -Existing $sel.Tag
    if ($edited) {
        $sel.Text = $edited.Name
        $sel.SubItems[1].Text = $edited.Alias
        $sel.SubItems[2].Text = $edited.Type
        $sel.SubItems[3].Text = $edited.Template
        $sel.SubItems[4].Text = $edited.Owners
        $sel.SubItems[5].Text = $edited.Members
        $sel.Tag = $edited
    }
})

$btnBulkAdd.Add_Click({
    $entry = Show-BulkSiteDialog -Owner $form
    if ($entry) { Add-BulkListItem $entry }
})

$btnBulkCsv.Add_Click({
    $ofd = New-Object System.Windows.Forms.OpenFileDialog
    $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*"
    if ($ofd.ShowDialog($form) -ne "OK") { return }
    # Try semicolon first (handles commas inside fields), fall back to comma
    $content = Get-Content $ofd.FileName -Raw
    if ($content -match ';') {
        $rows = Import-Csv $ofd.FileName -Delimiter ';'
    } else {
        $rows = Import-Csv $ofd.FileName
    }
    $count = 0
    foreach ($r in $rows) {
        # Read columns via PSObject properties (case-insensitive)
        $props = @{}
        foreach ($p in $r.PSObject.Properties) { $props[$p.Name.ToLower()] = "$($p.Value)".Trim() }

        $name = if ($props['name'])  { $props['name'] }  elseif ($props['title']) { $props['title'] } else { "" }
        $alias = if ($props['alias']) { $props['alias'] } elseif ($props['url'])   { $props['url'] }   else { "" }
        $type  = if ($props['type'])  { $props['type'] }  else { "Team" }
        $tpl   = if ($props['template']) { $props['template'] } else { "" }
        $own   = if ($props['owners']) { $props['owners'] } elseif ($props['owner']) { $props['owner'] } else { "" }
        $mem   = if ($props['members']) { $props['members'] } else { "" }

        # Name is required; skip empty rows
        if (-not $name) { continue }
        # Auto-generate alias from name if not provided
        if (-not $alias) {
            $alias = $name.ToLower() -replace '[^a-z0-9\-]', '-' -replace '-+', '-' -replace '^-|-$', ''
        }
        # Normalize type
        if ($type -match '^[Cc]omm') { $type = "Communication" } else { $type = "Team" }
        Add-BulkListItem @{
            Name     = $name
            Alias    = $alias
            Type     = $type
            Template = $tpl
            Owners   = $own
            Members  = $mem
        }
        $count++
    }
    Write-Log "$count site(s) imported from CSV." "LightGreen"
})

$btnBulkRemove.Add_Click({
    while ($lvBulk.SelectedItems.Count -gt 0) {
        $lvBulk.Items.Remove($lvBulk.SelectedItems[0])
    }
})

$btnBulkClear.Add_Click({
    $lvBulk.Items.Clear()
})

$btnBulkCreate.Add_Click({
    $clientId  = $txtClientId.Text.Trim()
    $tenantUrl = $txtTenantUrl.Text.Trim()

    if (-not $clientId)  { Write-Log "Client ID requis." "Red"; return }
    if (-not $tenantUrl) { Write-Log "Tenant URL requis." "Red"; return }
    if ($lvBulk.Items.Count -eq 0) { Write-Log "Aucun site dans la liste." "Red"; return }

    # Collect entries
    $entries = @()
    foreach ($lvi in $lvBulk.Items) { $entries += $lvi.Tag }

    # Load all templates once
    $allTemplates = @{}
    foreach ($t in (Load-Templates)) { $allTemplates[$t.name] = $t }

    $params = @{
        ClientId     = $clientId
        TenantUrl    = $tenantUrl
        Entries      = $entries
        Templates    = $allTemplates
    }

    $btnBulkCreate.Enabled = $false
    $btnBulkAdd.Enabled    = $false
    $btnBulkCsv.Enabled    = $false
    $btnBulkRemove.Enabled = $false
    $btnBulkClear.Enabled  = $false
    $txtLog.Clear()
    Start-ProgressAnim
    Write-Log "=== BULK SITE CREATION ===" "White"
    Write-Log "Sites to create: $($entries.Count)" "Gray"
    Write-Log ("-" * 52) "DarkGray"

    $bgBulk = {
        param($Params, $Sync)
        function BgLog([string]$m, [string]$c = "LightGreen") {
            $Sync.Queue.Enqueue(@{ Text = $m; Color = $c })
        }

        # Folder-tree helper (same as template manager)
        function Apply-FolderTreeBg([array]$folders, [string]$parentUrl) {
            foreach ($fd in $folders) {
                try {
                    Add-PnPFolder -Name $fd.name -Folder $parentUrl -ErrorAction SilentlyContinue | Out-Null
                } catch {}
                if ($fd.subfolders -and $fd.subfolders.Count -gt 0) {
                    Apply-FolderTreeBg $fd.subfolders "$parentUrl/$($fd.name)"
                }
            }
        }

        try {
            Import-Module PnP.PowerShell -ErrorAction Stop

            $adminUrl = if ($Params.TenantUrl -match '^(https?://[^.]+)(\.sharepoint\.com.*)') {
                "$($Matches[1])-admin$($Matches[2])"
            } else { $Params.TenantUrl }
            $base = if ($Params.TenantUrl -match '^(https?://[^.]+\.sharepoint\.com)') { $Matches[1] } else { $Params.TenantUrl }

            BgLog "Connexion au tenant admin..." "Yellow"
            Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $Params.ClientId

            $total = $Params.Entries.Count
            $idx   = 0

            foreach ($entry in $Params.Entries) {
                $idx++
                $name    = $entry.Name
                $alias   = $entry.Alias
                $isTeam  = $entry.Type -ne "Communication"
                $ownerRaw = "$($entry.Owners)"
                $memberRaw = "$($entry.Members)"
                $owners  = [string[]]@($ownerRaw  -split '[,;\s]+' | Where-Object { $_ -match '\S' })
                $members = [string[]]@($memberRaw -split '[,;\s]+' | Where-Object { $_ -match '\S' })
                $tplName = $entry.Template

                BgLog "[$idx/$total] Creating '$name' (alias: $alias, type: $($entry.Type))..." "White"
                BgLog "  DEBUG owners raw='$ownerRaw' parsed=[$($owners -join '|')] count=$($owners.Count)" "Gray"
                BgLog "  DEBUG members raw='$memberRaw' parsed=[$($members -join '|')] count=$($members.Count)" "Gray"

                # TeamSite requires at least one owner
                if ($isTeam -and $owners.Count -eq 0) {
                    BgLog "  ERREUR : TeamSite requires at least one owner — skipping '$name'" "Red"
                    $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Error: no owner" })
                    $Sync.ErrCount++
                    continue
                }

                # Update status
                $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Creating..." })

                try {
                    # Create the site WITHOUT owners/members (PnP bug: odata.bind empty array)
                    # Current user becomes default owner; we add owners/members after creation
                    Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $Params.ClientId
                    if ($isTeam) {
                        BgLog "  Creating TeamSite '$alias' (owners/members added after)..." "DarkGray"
                        $newUrl = New-PnPSite -Type TeamSite -Title $name -Alias $alias -Wait
                    } else {
                        BgLog "  Creating CommunicationSite '$alias'..." "DarkGray"
                        $newUrl = New-PnPSite -Type CommunicationSite -Title $name -Url "$base/sites/$alias" -Wait
                    }
                    BgLog "  Site cree : $newUrl" "LightGreen"

                    # Connect to the new site for owners/members/template
                    Connect-PnPOnline -Url $newUrl -Interactive -ClientId $Params.ClientId

                    # Assign owners & members post-creation
                    if ($isTeam) {
                        $groupId = $null
                        try { $groupId = (Get-PnPSite -Includes GroupId).GroupId.Guid } catch {}
                        if ($groupId) {
                            foreach ($o in $owners) {
                                try {
                                    Add-PnPMicrosoft365GroupOwner -Identity $groupId -Users $o -ErrorAction Stop
                                    BgLog "  Owner added: $o" "Cyan"
                                } catch { BgLog "  Warn owner '$o': $($_.Exception.Message)" "DarkYellow" }
                            }
                            foreach ($m in $members) {
                                try {
                                    Add-PnPMicrosoft365GroupMember -Identity $groupId -Users $m -ErrorAction Stop
                                    BgLog "  Member added: $m" "Cyan"
                                } catch { BgLog "  Warn member '$m': $($_.Exception.Message)" "DarkYellow" }
                            }
                        } else {
                            BgLog "  Could not get M365 GroupId — owners/members not assigned" "DarkYellow"
                        }
                    } else {
                        # CommunicationSite — classic SharePoint groups
                        if ($owners.Count -gt 0) {
                            $ownerGrp = Get-PnPGroup | Where-Object { $_.Title -like "*Propri*" -or $_.Title -like "*Owner*" } | Select-Object -First 1
                            if ($ownerGrp) {
                                foreach ($o in $owners) {
                                    try { Add-PnPGroupMember -LoginName $o -Group $ownerGrp.Title -ErrorAction SilentlyContinue } catch {}
                                }
                            }
                        }
                        if ($members.Count -gt 0) {
                            $memberGrp = Get-PnPGroup | Where-Object { $_.Title -like "*Membre*" -or $_.Title -like "*Member*" } | Select-Object -First 1
                            if ($memberGrp) {
                                foreach ($m in $members) {
                                    try { Add-PnPGroupMember -LoginName $m -Group $memberGrp.Title -ErrorAction SilentlyContinue } catch {}
                                }
                            }
                        }
                    }

                    # Apply template if specified
                    if ($tplName -and $Params.Templates.ContainsKey($tplName)) {
                        $tpl = $Params.Templates[$tplName]
                        BgLog "  Applying template '$tplName'..." "Yellow"

                        # Settings
                        if ($tpl.options.settings -and $tpl.settings -and $tpl.settings.description) {
                            Set-PnPWeb -Description $tpl.settings.description
                        }
                        # Style
                        if ($tpl.options.style -and $tpl.style -and $tpl.style.logoUrl) {
                            Set-PnPWeb -SiteLogoUrl $tpl.style.logoUrl
                        }
                        # Structure
                        if ($tpl.options.structure -and $tpl.structure -and $tpl.structure.Count -gt 0) {
                            foreach ($lib in $tpl.structure) {
                                $existing = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
                                if (-not $existing) {
                                    try {
                                        $tplType = if ($lib.template -eq 101 -or $lib.type -eq "DocumentLibrary") {
                                            [Microsoft.SharePoint.Client.ListTemplateType]::DocumentLibrary
                                        } else {
                                            [Microsoft.SharePoint.Client.ListTemplateType]::GenericList
                                        }
                                        New-PnPList -Title $lib.name -Template $tplType | Out-Null
                                    } catch {}
                                }
                                if ($lib.folders -and $lib.folders.Count -gt 0) {
                                    $targetList = Get-PnPList -Identity $lib.name -ErrorAction SilentlyContinue
                                    if ($targetList) {
                                        $listRf  = Get-PnPProperty -ClientObject $targetList -Property RootFolder
                                        $libBase = $listRf.ServerRelativeUrl.TrimEnd('/')
                                        Apply-FolderTreeBg $lib.folders $libBase
                                    }
                                }
                            }
                            BgLog "  Structure applied." "Cyan"
                        }
                    }



                    $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "OK" })
                    $Sync.CreatedSites.Add([PSCustomObject]@{
                        Name  = $name
                        Alias = $alias
                        Type  = $entry.Type
                        URL   = $newUrl
                    })
                    $Sync.OkCount++

                } catch {
                    $errMsg = $_.Exception.Message
                    BgLog "  ERREUR : $errMsg" "Red"
                    $Sync.Queue.Enqueue(@{ Text = "##STATUS##"; Index = ($idx - 1); Value = "Error: $errMsg" })
                    $Sync.ErrCount++
                }
            }

            BgLog "Termine : $($Sync.OkCount) OK, $($Sync.ErrCount) erreur(s)" "White"
        } catch {
            $Sync.Error = $_.Exception.Message
            BgLog "Erreur globale : $($_.Exception.Message)" "Red"
        } finally {
            $Sync.Done = $true
        }
    }

    $sync = [hashtable]::Synchronized(@{
        Queue        = [System.Collections.Generic.Queue[object]]::new()
        Done         = $false
        Error        = $null
        OkCount      = 0
        ErrCount     = 0
        CreatedSites = [System.Collections.Generic.List[object]]::new()
    })
    $script:_BulkSync = $sync

    $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
    $rs.ApartmentState = "STA"; $rs.ThreadOptions = "ReuseThread"; $rs.Open()
    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.Runspace = $rs
    [void]$ps.AddScript($bgBulk)
    [void]$ps.AddArgument($params)
    [void]$ps.AddArgument($sync)
    $script:_BulkRS  = $rs
    $script:_BulkPS  = $ps
    $script:_BulkHnd = $ps.BeginInvoke()

    $tmr          = New-Object System.Windows.Forms.Timer
    $tmr.Interval = 250
    $script:_BulkTimer = $tmr
    $tmr.Add_Tick({
        while ($script:_BulkSync.Queue.Count -gt 0) {
            $m = $script:_BulkSync.Queue.Dequeue()
            # Status update messages update the ListView item
            if ($m.Text -eq "##STATUS##") {
                $i = $m.Index
                if ($i -lt $lvBulk.Items.Count) {
                    $lvi = $lvBulk.Items[$i]
                    # Use the first column text to show status via ForeColor
                    if ($m.Value -eq "OK") {
                        $lvi.ForeColor = [System.Drawing.Color]::Green
                    } elseif ($m.Value -like "Error*") {
                        $lvi.ForeColor = [System.Drawing.Color]::Red
                    } else {
                        $lvi.ForeColor = [System.Drawing.Color]::DarkOrange
                    }
                }
            } else {
                Write-Log $m.Text $m.Color
            }
        }
        if ($script:_BulkSync.Done) {
            $script:_BulkTimer.Stop(); $script:_BulkTimer.Dispose()
            while ($script:_BulkSync.Queue.Count -gt 0) {
                $m = $script:_BulkSync.Queue.Dequeue()
                if ($m.Text -ne "##STATUS##") { Write-Log $m.Text $m.Color }
            }
            try { [void]$script:_BulkPS.EndInvoke($script:_BulkHnd) } catch {}
            try { $script:_BulkRS.Close(); $script:_BulkRS.Dispose() } catch {}
            $btnBulkCreate.Enabled = $true
            $btnBulkAdd.Enabled    = $true
            $btnBulkCsv.Enabled    = $true
            $btnBulkRemove.Enabled = $true
            $btnBulkClear.Enabled  = $true
            Stop-ProgressAnim

            if ($script:_BulkSync.Error) {
                Write-Log "Echec : $($script:_BulkSync.Error)" "Red"
                return
            }
            # Export CSV report of created sites
            if ($script:_BulkSync.CreatedSites.Count -gt 0) {
                $outDir = $txtOutput.Text.Trim()
                if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
                if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
                $stamp   = Get-Date -Format "yyyyMMdd_HHmmss"
                $csvFile = Join-Path $outDir "BulkCreate_$stamp.csv"
                $script:_BulkSync.CreatedSites | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8
                Write-Log "Rapport CSV : $csvFile" "White"
            }
            Write-Log "=== BULK CREATE COMPLETE: $($script:_BulkSync.OkCount) OK, $($script:_BulkSync.ErrCount) error(s) ===" "White"
        }
    })
    $tmr.Start()
})

#endregion

#region ===== Structure (folder tree from CSV) =====

# Store the parsed folder paths
$script:_StructPaths = @()

function Build-StructTree([string]$csvPath) {
    # Auto-detect delimiter
    $raw = Get-Content $csvPath -Raw
    $delim = if ($raw -match ';') { ';' } else { ',' }
    $rows = Import-Csv $csvPath -Delimiter $delim

    $paths = [System.Collections.Generic.List[string]]::new()
    foreach ($r in $rows) {
        $cols = @($r.PSObject.Properties | ForEach-Object { "$($_.Value)".Trim() })
        # Build path from non-empty columns
        $parts = @($cols | Where-Object { $_ -ne '' })
        if ($parts.Count -gt 0) {
            # Add all intermediate paths to ensure parents exist
            for ($i = 1; $i -le $parts.Count; $i++) {
                $p = ($parts[0..($i-1)] -join '/')
                if (-not $paths.Contains($p)) { $paths.Add($p) }
            }
        }
    }
    $script:_StructPaths = @($paths | Sort-Object)
    return $script:_StructPaths
}

function Populate-StructTreeView([string[]]$paths) {
    $tvStruct.Nodes.Clear()
    $nodeMap = @{}
    foreach ($p in $paths) {
        $parts = $p -split '/'
        $parentKey = if ($parts.Count -gt 1) { ($parts[0..($parts.Count - 2)] -join '/') } else { '' }
        $name = $parts[-1]
        $node = New-Object System.Windows.Forms.TreeNode($name)
        $node.Tag = $p
        if ($parentKey -and $nodeMap.ContainsKey($parentKey)) {
            $nodeMap[$parentKey].Nodes.Add($node) | Out-Null
        } else {
            $tvStruct.Nodes.Add($node) | Out-Null
        }
        $nodeMap[$p] = $node
    }
    $tvStruct.ExpandAll()
}

$btnStructCsv.Add_Click({
    $ofd = New-Object System.Windows.Forms.OpenFileDialog
    $ofd.Filter = "CSV (*.csv)|*.csv|All (*.*)|*.*"
    if ($ofd.ShowDialog($form) -ne "OK") { return }
    try {
        $paths = Build-StructTree $ofd.FileName
        Populate-StructTreeView $paths
        Write-Log "$($paths.Count) folder(s) loaded from CSV." "LightGreen"
    } catch {
        Write-Log "CSV error: $($_.Exception.Message)" "Red"
    }
})

$btnStructClear.Add_Click({
    $tvStruct.Nodes.Clear()
    $script:_StructPaths = @()
    Write-Log "Structure cleared." "Gray"
})

$btnStructCreate.Add_Click({
    $siteUrl   = $txtSiteUrl.Text.Trim()
    $clientId  = $txtClientId.Text.Trim()
    $library   = $txtStructLib.Text.Trim()

    if (-not $siteUrl)   { Write-Log "Site URL required." "Red"; return }
    if (-not $clientId)  { Write-Log "Client ID required." "Red"; return }
    if (-not $library)   { Write-Log "Target library required." "Red"; return }
    if ($script:_StructPaths.Count -eq 0) { Write-Log "No structure loaded. Load a CSV first." "Red"; return }

    $btnStructCreate.Enabled = $false
    $btnStructCsv.Enabled    = $false
    Start-ProgressAnim
    Write-Log "=== CREATING FOLDER STRUCTURE ===" "White"
    Write-Log "Target: $siteUrl / $library" "Gray"
    Write-Log "Folders to create: $($script:_StructPaths.Count)" "Gray"
    Write-Log ("-" * 52) "DarkGray"

    try {
        Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId

        # Get the library root
        $list = Get-PnPList -Identity $library -ErrorAction Stop
        $rf   = Get-PnPProperty -ClientObject $list -Property RootFolder
        $base = $rf.ServerRelativeUrl.TrimEnd('/')

        $ok  = 0
        $err = 0
        $total = $script:_StructPaths.Count
        foreach ($p in $script:_StructPaths) {
            $folderPath = "$base/$p"
            try {
                # Resolve-PnPFolder creates the full path recursively
                Resolve-PnPFolder -SiteRelativePath "$library/$p" -ErrorAction Stop | Out-Null
                $ok++
                Write-Log "  OK: $p" "LightGreen"
            } catch {
                $err++
                Write-Log "  FAIL: $p — $($_.Exception.Message)" "Red"
            }
        }
        Write-Log "=== STRUCTURE COMPLETE: $ok OK, $err error(s) ===" "White"
    } catch {
        Write-Log "Error: $($_.Exception.Message)" "Red"
    } finally {
        $btnStructCreate.Enabled = $true
        $btnStructCsv.Enabled    = $true
        Stop-ProgressAnim
    }
})

# ── Version Cleanup handlers ─────────────────────────────────────────────────
$script:_VerReport = $null

$btnVerOpen.Add_Click({
    if ($script:_VerReport -and (Test-Path $script:_VerReport)) {
        Start-Process $script:_VerReport
    }
})

$btnVerRun.Add_Click({
    # --- Gather all selected site URLs ---
    $siteUrls = if ($script:SelectedSites -and $script:SelectedSites.Count -gt 0) {
        @($script:SelectedSites)
    } else {
        @($txtSiteURL.Text.Trim())
    }
    $siteUrls = @($siteUrls | Where-Object { $_ })
    if ($siteUrls.Count -eq 0) { Write-Log "Site URL required." "Red"; return }

    $clientId = $txtClientId.Text.Trim()
    if (-not $clientId) { Write-Log "Client ID required." "Red"; return }

    $keepCount  = [int]$nudVerCount.Value
    $useDate    = $chkVerDate.Checked
    $dateBefore = $radVerBefore.Checked   # true = keep before, false = keep after
    $cutoffDate = $dtpVer.Value
    $library    = $txtVerLib.Text.Trim()
    $recursive  = $chkVerRecursive.Checked
    $subsites   = $chkVerSubsites.Checked
    $dryRun     = $chkVerDryRun.Checked

    $btnVerRun.Enabled = $false
    Start-ProgressAnim
    $modeLabel = if ($dryRun) { "DRY RUN" } else { "LIVE" }
    Write-Log "=== VERSION CLEANUP ($modeLabel) ===" "White"
    Write-Log "Keep: $keepCount version(s)" "Gray"
    if ($useDate) {
        $dir = if ($dateBefore) { "before" } else { "after" }
        Write-Log "Date filter: keep versions $dir $($cutoffDate.ToString('yyyy-MM-dd'))" "Gray"
    }
    Write-Log ("-" * 52) "DarkGray"

    $report = [System.Collections.Generic.List[object]]::new()
    $totalDeleted = 0
    $totalKept    = 0
    $totalErrors  = 0

    try {
        foreach ($siteUrl in $siteUrls) {
            Write-Log "Connecting to $siteUrl ..." "Gray"
            Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId

            # Collect site URLs to process (main + subsites)
            $sitesToProcess = @($siteUrl)
            if ($subsites) {
                try {
                    $subs = Get-PnPSubWeb -Recurse -ErrorAction SilentlyContinue
                    foreach ($sw in $subs) { $sitesToProcess += $sw.Url }
                } catch {}
            }

            foreach ($currentSite in $sitesToProcess) {
                if ($currentSite -ne $siteUrl) {
                    try { Connect-PnPOnline -Url $currentSite -Interactive -ClientId $clientId } catch {
                        Write-Log "  Cannot connect to subsite $currentSite — skipped" "DarkOrange"
                        continue
                    }
                }
                Write-Log "Processing site: $currentSite" "White"

                # Get target lists
                $lists = @()
                if ($library) {
                    try { $lists = @(Get-PnPList -Identity $library -ErrorAction Stop) } catch {
                        Write-Log "  Library '$library' not found — skipped" "DarkOrange"
                        continue
                    }
                } else {
                    $lists = Get-PnPList | Where-Object { $_.BaseTemplate -eq 101 -and $_.Hidden -eq $false }
                }

                foreach ($list in $lists) {
                    Write-Log "  Library: $($list.Title)" "Gray"
                    try {
                        $camlQuery = "<View Scope='RecursiveAll'><Query></Query><RowLimit>5000</RowLimit></View>"
                        if (-not $recursive) {
                            $camlQuery = "<View><Query></Query><RowLimit>5000</RowLimit></View>"
                        }
                        $items = Get-PnPListItem -List $list.Title -Query $camlQuery -ErrorAction Stop |
                                 Where-Object { $_.FileSystemObjectType -eq "File" }
                    } catch {
                        Write-Log "    Error listing files: $($_.Exception.Message)" "Red"
                        $totalErrors++
                        continue
                    }

                    foreach ($item in $items) {
                        try {
                            $file     = $item.FieldValues["FileRef"]
                            $versions = Get-PnPFileVersion -Url $file -ErrorAction Stop

                            if ($versions.Count -le $keepCount) { continue }

                            # Sort versions oldest first (by VersionLabel numeric)
                            $sorted = $versions | Sort-Object { [double]$_.VersionLabel }

                            # Determine which versions to delete
                            $toDelete = @()
                            foreach ($v in $sorted) {
                                # Always keep the last $keepCount versions
                                $idx = [array]::IndexOf($sorted, $v)
                                $remaining = $sorted.Count - $idx
                                if ($remaining -le $keepCount) { break }

                                # Apply date filter if enabled
                                if ($useDate) {
                                    $vDate = [datetime]$v.Created
                                    if ($dateBefore) {
                                        # Keep versions before cutoff → delete versions ON or AFTER cutoff
                                        if ($vDate -lt $cutoffDate) { continue }
                                    } else {
                                        # Keep versions after cutoff → delete versions BEFORE cutoff
                                        if ($vDate -ge $cutoffDate) { continue }
                                    }
                                }

                                $toDelete += $v
                            }

                            if ($toDelete.Count -eq 0) { continue }

                            $fileName = Split-Path $file -Leaf
                            foreach ($v in $toDelete) {
                                if ($dryRun) {
                                    Write-Log "    [DRY] Would delete v$($v.VersionLabel) of $fileName ($($v.Created))" "DarkOrange"
                                } else {
                                    try {
                                        Remove-PnPFileVersion -Url $file -Identity $v.Id -Force -ErrorAction Stop
                                        Write-Log "    Deleted v$($v.VersionLabel) of $fileName" "LightGreen"
                                    } catch {
                                        Write-Log "    Error deleting v$($v.VersionLabel) of $fileName — $($_.Exception.Message)" "Red"
                                        $totalErrors++
                                    }
                                }
                                $totalDeleted++
                            }

                            $kept = $sorted.Count - $toDelete.Count
                            $totalKept += $kept

                            $report.Add([PSCustomObject]@{
                                Site       = $currentSite
                                Library    = $list.Title
                                File       = $file
                                TotalVer   = $sorted.Count
                                Deleted    = $toDelete.Count
                                Kept       = $kept
                            })
                        } catch {
                            $totalErrors++
                        }
                    }
                }
            }
        }

        # Export CSV report
        if ($report.Count -gt 0) {
            $outDir = $txtOutput.Text.Trim()
            if (-not $outDir) { $outDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } }
            if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
            $stamp   = Get-Date -Format "yyyyMMdd_HHmmss"
            $prefix  = if ($dryRun) { "VersionCleanup_DryRun" } else { "VersionCleanup" }
            $csvFile = Join-Path $outDir "${prefix}_$stamp.csv"
            $report | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8
            $script:_VerReport = $csvFile
            $btnVerOpen.Enabled = $true
            Write-Log "Report: $csvFile" "White"
        }

        Write-Log "=== VERSION CLEANUP COMPLETE: $totalDeleted deleted, $totalKept kept, $totalErrors error(s) ===" "White"
    } catch {
        Write-Log "Error: $($_.Exception.Message)" "Red"
    } finally {
        $btnVerRun.Enabled = $true
        Stop-ProgressAnim
    }
})

#endregion

# ── Initialisation : chargement des settings ───────────────────────────────
$_settings = Load-Settings
$script:DataFolder = if ($_settings.dataFolder -and (Test-Path $_settings.dataFolder)) {
    $_settings.dataFolder
} elseif ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }

# Load saved language (applies T() translations and updates all registered controls)
$_savedLang = if ($_settings.lang) { $_settings.lang } else { "en" }
if ($_savedLang -ne "en") {
    Load-Language $_savedLang
    Update-UILanguage
    foreach ($mi in $menuLang.DropDownItems) {
        if ($mi -is [System.Windows.Forms.ToolStripMenuItem]) {
            $mi.Checked = ($mi.Tag -eq $script:CurrentLang)
        }
    }
}

Refresh-ProfileList
$n = (Load-Templates).Count
$lblTplCount.Text = "$n $(T 'tpl.count')"

[System.Windows.Forms.Application]::Run($form)
