[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$PackagePath,
    [Parameter(Mandatory = $true)]
    [string]$TargetDir,
    [Parameter(Mandatory = $true)]
    [string]$LogFile,
    [string]$PendingStatePath = '',
    [int]$ParentProcessId = 0,
    [switch]$SkipAdministratorCheck,
    [switch]$SkipProcessCheck,
    [switch]$SimulateFailureAfterInstall
)

$ErrorActionPreference = 'Stop'
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$programDataRoot = Join-Path $env:ProgramData 'MyBiz_ACCGST'
$backupRoot = Join-Path $programDataRoot "UpdateBackups\OTA-$timestamp"
$stagingRoot = Join-Path $env:TEMP ('mybiz-ota-' + [Guid]::NewGuid().ToString('N'))
$manifestPath = Join-Path $stagingRoot 'payload-manifest.json'
$protectedNames = @('mybiz_accgst.exe.config')
$protectedExtensions = @('.mdf', '.ldf')
$installStarted = $false
$backupCreated = $false

function Write-Log {
    param([string]$Message)
    $line = '{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Message
    Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8
}

function Assert-Administrator {
    if ($SkipAdministratorCheck) { return }
    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal($identity)
    if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw 'Administrator permission is required.'
    }
}

function Assert-DotNet48 {
    $release = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -Name Release).Release
    if (-not $release -or [int]$release -lt 528040) {
        throw '.NET Framework 4.8 or later is required.'
    }
}

function Assert-NoRunningMyBiz {
    if ($SkipProcessCheck) { return }
    if ($ParentProcessId -gt 0) {
        $parent = Get-Process -Id $ParentProcessId -ErrorAction SilentlyContinue
        if ($parent -and $parent.ProcessName -ieq 'MyBiz_ACCGST') {
            Write-Log "Waiting for parent MyBiz process to exit: PID $ParentProcessId"
            $exited = $parent.WaitForExit(120000)
            if (-not $exited) {
                try { [void]$parent.CloseMainWindow() } catch {}
                $exited = $parent.WaitForExit(30000)
            }
            if (-not $exited) {
                throw "MyBiz did not close within 150 seconds. Parent PID still running: $ParentProcessId"
            }
            Write-Log "Parent MyBiz process exited: PID $ParentProcessId"
        }
        elseif ($parent) {
            Write-Log "Parent process PID $ParentProcessId is no longer MyBiz_ACCGST; current name: $($parent.ProcessName)"
        }
        else {
            Write-Log "Parent MyBiz process is already closed: PID $ParentProcessId"
        }
    }

    $deadline = (Get-Date).AddSeconds(150)
    do {
        $running = @(Get-Process -Name 'MyBiz_ACCGST' -ErrorAction SilentlyContinue | Where-Object { $_.Id -ne $ParentProcessId })
        if ($running.Count -eq 0) {
            Write-Log 'No running MyBiz_ACCGST process remains.'
            return
        }

        $details = ($running | ForEach-Object {
            $title = ''
            try { $title = $_.MainWindowTitle } catch {}
            if ([string]::IsNullOrWhiteSpace($title)) { $title = '(no window title)' }
            "$($_.Id):$title"
        }) -join ', '
        Write-Log "Waiting for other MyBiz_ACCGST instances to close. PIDs/titles: $details"
        foreach ($process in $running) {
            try {
                if ($process.MainWindowHandle -ne 0) { [void]$process.CloseMainWindow() }
            } catch {}
        }
        Start-Sleep -Seconds 5
    } while ((Get-Date) -lt $deadline)

    $running = @(Get-Process -Name 'MyBiz_ACCGST' -ErrorAction SilentlyContinue | Where-Object { $_.Id -ne $ParentProcessId })
    if ($running.Count -gt 0) {
        $ids = ($running | ForEach-Object { $_.Id }) -join ', '
        throw "Another MyBiz_ACCGST instance is still running after waiting. Close it and run the update again. PIDs: $ids"
    }
}

function Assert-RelativePath {
    param([string]$Path)
    if ([string]::IsNullOrWhiteSpace($Path) -or [IO.Path]::IsPathRooted($Path) -or $Path.Contains('..')) {
        throw "Unsafe update path: $Path"
    }
}

function Test-ProtectedPath {
    param([string]$Path)
    $p = $Path.Replace('/', '\').TrimStart('\').ToLowerInvariant()
    $name = [IO.Path]::GetFileName($p)
    $ext = [IO.Path]::GetExtension($p)
    if ($protectedNames -contains $name) { return $true }
    if ($protectedExtensions -contains $ext) { return $true }
    if ($p.Contains('\node_modules\')) { return $false }
    if ($name -eq 'license' -or $name -eq 'licence' -or $name.EndsWith('.lic') -or $name.StartsWith('activation')) { return $true }
    if ($p.StartsWith('updates\') -or $p.StartsWith('updatelogs\') -or $p.StartsWith('updatebackups\')) { return $true }
    if ($p.StartsWith('whatsappsessions\') -or $p.Contains('\auth_info') -or $p.Contains('\session')) { return $true }
    if ($p.StartsWith('database\') -or $p.StartsWith('backup\') -or $p.StartsWith('backups\')) { return $true }
    return $false
}

function Get-FileSha256 {
    param([string]$Path)
    return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToUpperInvariant()
}

function Assert-ManifestFiles {
    param($Manifest, [string]$Root)
    foreach ($file in $Manifest.Files) {
        $relative = [string]$file.Path
        Assert-RelativePath $relative
        if (Test-ProtectedPath $relative) { throw "Update package contains protected file: $relative" }
        $path = Join-Path $Root $relative
        if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Package file is missing: $relative" }
        $item = Get-Item -LiteralPath $path
        if ([long]$item.Length -ne [long]$file.Length) { throw "Package file size verification failed: $relative" }
        if ((Get-FileSha256 $path) -ne ([string]$file.Sha256).ToUpperInvariant()) { throw "Package file hash verification failed: $relative" }
    }
    foreach ($path in @($Manifest.DeletePaths)) {
        if ($null -eq $path) { continue }
        Assert-RelativePath ([string]$path)
        if (Test-ProtectedPath ([string]$path)) { throw "Update package tries to delete protected file: $path" }
    }
}

function Copy-DirectoryFileBackup {
    param([string]$Relative)
    $source = Join-Path $TargetDir $Relative
    if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { return }
    $destination = Join-Path $backupRoot $Relative
    New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null
    Copy-FileWithRetry -Source $source -Destination $destination -Label "backup $Relative"
}

function Test-FileUnlockedForWrite {
    param([string]$Path)
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $true }
    $stream = $null
    try {
        $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
        return $true
    }
    catch {
        return $false
    }
    finally {
        if ($null -ne $stream) { $stream.Dispose() }
    }
}

function Wait-TargetFilesUnlocked {
    param($Manifest)

    Write-Log 'Checking target files for locks before install.'
    $deadline = (Get-Date).AddSeconds(180)
    do {
        $locked = New-Object System.Collections.Generic.List[string]
        foreach ($file in $Manifest.Files) {
            $relative = [string]$file.Path
            $targetPath = Join-Path $TargetDir $relative
            if (-not (Test-FileUnlockedForWrite -Path $targetPath)) {
                [void]$locked.Add($relative)
            }
        }
        foreach ($path in @($Manifest.DeletePaths)) {
            if ($null -eq $path) { continue }
            $relative = [string]$path
            $targetPath = Join-Path $TargetDir $relative
            if (-not (Test-FileUnlockedForWrite -Path $targetPath)) {
                [void]$locked.Add($relative)
            }
        }

        if ($locked.Count -eq 0) {
            Write-Log 'All target files are writable/unlocked.'
            return
        }

        $sample = ($locked | Select-Object -First 10) -join ', '
        Write-Log "Waiting for locked target files to be released. Count=$($locked.Count). Sample: $sample"
        Assert-NoRunningMyBiz
        Start-Sleep -Seconds 5
    } while ((Get-Date) -lt $deadline)

    $sample = ($locked | Select-Object -First 20) -join ', '
    throw "Update cannot continue because target files are still locked before install. No files were changed. Locked sample: $sample"
}

function Copy-FileWithRetry {
    param(
        [string]$Source,
        [string]$Destination,
        [string]$Label = ''
    )

    $lastError = $null
    for ($attempt = 1; $attempt -le 20; $attempt++) {
        try {
            Copy-Item -LiteralPath $Source -Destination $Destination -Force
            return
        }
        catch {
            $lastError = $_.Exception
            Write-Log "Copy retry $attempt failed for $Label. $($lastError.Message)"
            Start-Sleep -Seconds 2
        }
    }

    throw "Copy failed after retries for $Label. $($lastError.Message)"
}

function Restore-Backup {
    Write-Log 'Rollback started.'
    if (-not (Test-Path -LiteralPath $backupRoot)) {
        Write-Log 'No rollback backup was created.'
        return
    }
    Get-ChildItem -LiteralPath $backupRoot -Recurse -File | ForEach-Object {
        $relative = $_.FullName.Substring($backupRoot.Length + 1)
        $destination = Join-Path $TargetDir $relative
        New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null
        Copy-FileWithRetry -Source $_.FullName -Destination $destination -Label "rollback $relative"
        Write-Log "Restored: $relative"
    }
    Write-Log 'Rollback completed.'
}

New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogFile) | Out-Null
[IO.File]::WriteAllText($LogFile, '')

try {
    Write-Log 'Starting MyBiz OTA update.'
    Write-Log "Package: $PackagePath"
    Write-Log "Target: $TargetDir"
    if (-not [string]::IsNullOrWhiteSpace($PendingStatePath)) {
        Write-Log "Pending state: $PendingStatePath"
    }

    Write-Log 'Checking administrator permission.'
    Assert-Administrator
    Write-Log 'Checking .NET Framework 4.8 runtime.'
    Assert-DotNet48
    Write-Log 'Checking running MyBiz processes.'
    Assert-NoRunningMyBiz

    Write-Log 'Checking target folder.'
    if (-not (Test-Path -LiteralPath (Join-Path $TargetDir 'MyBiz_ACCGST.exe') -PathType Leaf)) {
        throw 'Target folder does not contain MyBiz_ACCGST.exe.'
    }
    if (-not (Test-Path -LiteralPath (Join-Path $TargetDir 'MyBiz_ACCGST.exe.config') -PathType Leaf)) {
        throw 'Target folder does not contain MyBiz_ACCGST.exe.config.'
    }
    if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) {
        throw "Update package not found: $PackagePath"
    }

    Write-Log "Extracting package to staging: $stagingRoot"
    New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null
    Expand-Archive -LiteralPath $PackagePath -DestinationPath $stagingRoot -Force
    if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
        throw 'payload-manifest.json is missing from OTA package.'
    }

    Write-Log 'Verifying payload manifest and file hashes.'
    $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
    if (-not $manifest.Version -or -not $manifest.Files) { throw 'Payload manifest is invalid.' }
    Assert-ManifestFiles -Manifest $manifest -Root $stagingRoot
    Write-Log "Payload verified: $($manifest.Files.Count) files."
    Wait-TargetFilesUnlocked -Manifest $manifest

    Write-Log "Creating backup: $backupRoot"
    New-Item -ItemType Directory -Force -Path $backupRoot | Out-Null
    foreach ($file in $manifest.Files) {
        Copy-DirectoryFileBackup ([string]$file.Path)
    }
    foreach ($path in @($manifest.DeletePaths)) {
        if ($null -ne $path) { Copy-DirectoryFileBackup ([string]$path) }
    }
    Copy-Item -LiteralPath (Join-Path $TargetDir 'MyBiz_ACCGST.exe.config') -Destination (Join-Path $backupRoot 'MyBiz_ACCGST.exe.config.reference-only') -Force
    $backupCreated = $true
    Write-Log "Backup completed: $backupRoot"

    $installStarted = $true
    Write-Log 'Installing payload files.'
    foreach ($path in @($manifest.DeletePaths)) {
        if ($null -eq $path) { continue }
        $targetPath = Join-Path $TargetDir ([string]$path)
        if (Test-Path -LiteralPath $targetPath -PathType Leaf) {
            Remove-Item -LiteralPath $targetPath -Force
            Write-Log "Deleted: $path"
        }
    }

    foreach ($file in $manifest.Files) {
        $relative = [string]$file.Path
        $source = Join-Path $stagingRoot $relative
        $destination = Join-Path $TargetDir $relative
        New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null
        Copy-FileWithRetry -Source $source -Destination $destination -Label "install $relative"
        Write-Log "Installed: $relative"
    }

    if ($SimulateFailureAfterInstall) {
        throw 'Simulated post-install failure requested by test.'
    }

    Write-Log 'Verifying installed files.'
    Assert-ManifestFiles -Manifest $manifest -Root $TargetDir
    if (-not (Test-Path -LiteralPath (Join-Path $TargetDir 'MyBiz_ACCGST.exe.config') -PathType Leaf)) {
        throw 'Protected configuration file is missing after update.'
    }

    if (-not [string]::IsNullOrWhiteSpace($PendingStatePath) -and (Test-Path -LiteralPath $PendingStatePath -PathType Leaf)) {
        Remove-Item -LiteralPath $PendingStatePath -Force
        Write-Log 'Pending update state cleared.'
    }

    Write-Log "MyBiz OTA update $($manifest.Version) completed successfully."
    exit 0
}
catch {
    Write-Log ('ERROR: ' + $_.Exception.Message)
    if ($installStarted -and $backupCreated) {
        try {
            Restore-Backup
        }
        catch {
            Write-Log ('ROLLBACK ERROR: ' + $_.Exception.Message)
        }
    }
    exit 1
}
finally {
    if (Test-Path -LiteralPath $stagingRoot) {
        Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue
    }
}
