lib/GitHubApi.ps1

#############################################################################
# GitHubApi.ps1 - GitHub REST API Interactions
#############################################################################
# This module provides functions for interacting with the GitHub REST API
# for releases, tags, branches, and other git references.
#############################################################################

#############################################################################
# Retry Helper Function
# Provides exponential backoff retry logic for API calls
#############################################################################

function Invoke-WithRetry
{
    param(
        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,

        [int]$MaxRetries = 3,
        [int]$InitialDelaySeconds = 1,
        [string]$OperationDescription = "operation"
    )

    # Check if all direct GitHub API calls are disabled via environment variable.
    # Wrapper-backed calls are still allowed so tests can mock HTTP behavior.
    # Set $env:GITHUB_API_DISABLE_API = 'true' to fail immediately on real network calls.
    if ($env:GITHUB_API_DISABLE_API -eq 'true' -and -not (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue)) {
        throw "GitHub API calls are disabled for $OperationDescription (GITHUB_API_DISABLE_API=true)"
    }

    # Check if retries are disabled via environment variable (useful for faster test execution)
    # Set $env:GITHUB_API_DISABLE_RETRY = 'true' to skip retries and fail immediately
    if ($env:GITHUB_API_DISABLE_RETRY -eq 'true') {
        Write-Host "::debug::Retries disabled for $OperationDescription (GITHUB_API_DISABLE_RETRY=true)"
        return & $ScriptBlock
    }

    $attempt = 0
    $delay = $InitialDelaySeconds

    while ($attempt -lt $MaxRetries) {
        $attempt++

        try {
            Write-Host "::debug::Attempt $attempt of $MaxRetries for $OperationDescription"
            return & $ScriptBlock
        }
        catch {
            $errorMessage = $_.Exception.Message

            if ($errorMessage -match 'GITHUB_API_DISABLE_API=true') {
                Write-Host "::debug::API disabled guard triggered for $OperationDescription"
                throw
            }

            # Check if this is a retryable error (network, timeout, rate limit)
            $isRetryable = $errorMessage -match '(timeout|connection|429|503|502|500)' -or `
                $_.Exception.GetType().Name -match '(WebException|HttpRequestException)'

            if ($attempt -ge $MaxRetries -or -not $isRetryable) {
                Write-Host "::debug::Non-retryable error or max retries reached for $OperationDescription"
                throw
            }

            Write-Host "::warning::$OperationDescription failed (attempt $attempt/$MaxRetries): $errorMessage. Retrying in $delay seconds..."
            Start-Sleep -Seconds $delay

            # Exponential backoff: double the delay each time
            $delay = $delay * 2
        }
    }

    # Safeguard: This should not be reached due to the throw on line 43, but add explicit error
    throw "Maximum retry attempts ($MaxRetries) reached for $OperationDescription without success or error"
}

function Get-ApiHeader {
    param(
        [string]$Token
    )

    $headers = @{
        'Accept' = 'application/vnd.github+json'
        'X-GitHub-Api-Version' = '2022-11-28'
    }

    if ($Token) {
        $headers['Authorization'] = "Bearer $Token"
    }

    return $headers
}

function Assert-GitHubApiEnabled {
    param(
        [string]$OperationDescription = "operation"
    )

    if ($env:GITHUB_API_DISABLE_API -eq 'true' -and -not (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue)) {
        throw "GitHub API calls are disabled for $OperationDescription (GITHUB_API_DISABLE_API=true)"
    }
}

function Invoke-GitHubHttpRequest {
    param(
        [Parameter(Mandatory)]
        [string]$Uri,
        [Parameter(Mandatory)]
        [hashtable]$Headers,
        [Parameter(Mandatory)]
        [ValidateSet('Get', 'Post', 'Patch', 'Delete', 'Head')]
        [string]$Method,
        [string]$Body,
        [string]$ContentType,
        [int]$TimeoutSec = 10,
        [string]$OperationDescription = 'operation',
        [switch]$ParseJsonContent
    )

    $requestParameters = @{
        Uri = $Uri
        Headers = $Headers
        Method = $Method
        ErrorAction = 'Stop'
        TimeoutSec = $TimeoutSec
    }

    if ($PSBoundParameters.ContainsKey('Body')) {
        $requestParameters['Body'] = $Body
    }
    if ($PSBoundParameters.ContainsKey('ContentType')) {
        $requestParameters['ContentType'] = $ContentType
    }

    $response = $null
    if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
        $response = Invoke-WebRequestWrapper @requestParameters
    } else {
        Assert-GitHubApiEnabled -OperationDescription $OperationDescription
        $response = Invoke-RestMethod @requestParameters
    }

    if ($ParseJsonContent) {
        if ($response -is [System.Collections.IDictionary] -and $response.ContainsKey('Content')) {
            return $response.Content | ConvertFrom-Json
        }

        $contentProperty = $response.PSObject.Properties['Content']
        if ($contentProperty -and $contentProperty.Value) {
            return $contentProperty.Value | ConvertFrom-Json
        }
    }

    return $response
}

function Throw-GitHubApiFailure
{
    param(
        [Parameter(Mandatory)]
        [string]$Operation,
        [Parameter(Mandatory)]
        $ErrorRecord
    )

    $detailMessage = $null
    $statusCode = $null

    if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) {
        if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
            $detailMessage = $ErrorRecord.ErrorDetails.Message
        } elseif ($ErrorRecord.Exception -and $ErrorRecord.Exception.Message) {
            $detailMessage = $ErrorRecord.Exception.Message
        }

        if ($ErrorRecord.Exception -and $ErrorRecord.Exception.Response) {
            $statusCode = $ErrorRecord.Exception.Response.StatusCode.value__
        }
    }

    if (-not $detailMessage) {
        $detailMessage = [string]$ErrorRecord
    }

    $statusSuffix = if ($statusCode) { " (HTTP $statusCode)" } else { "" }
    Write-SafeOutput -Message $detailMessage -Prefix "::error::GitHub API request failed during $Operation$statusSuffix. "

    throw "GitHub API request failed during $Operation$statusSuffix"
}

function Test-VersionShouldBeIgnored {
    <#
    .SYNOPSIS
    Tests if a version should be ignored based on the ignore-versions patterns.

    .DESCRIPTION
    Checks if a version matches any of the ignore patterns. Supports both exact
    matches and wildcard patterns (using PowerShell's -like operator).

    .PARAMETER Version
    The version string to check (e.g., "v1.0.0", "v2.1")

    .PARAMETER IgnoreVersions
    Array of patterns to match against. Supports wildcards like "v1.*"

    .EXAMPLE
    Test-VersionShouldBeIgnored -Version "v1.0.0" -IgnoreVersions @("v1.*")
    # Returns $true

    .EXAMPLE
    Test-VersionShouldBeIgnored -Version "v2.0.0" -IgnoreVersions @("v1.0.0", "v1.1.0")
    # Returns $false
    #>

    param(
        [Parameter(Mandatory)]
        [string]$Version,

        [string[]]$IgnoreVersions = @()
    )

    if (-not $IgnoreVersions -or $IgnoreVersions.Count -eq 0) {
        return $false
    }

    foreach ($pattern in $IgnoreVersions) {
        # Use -like for wildcard pattern matching (supports *, ?)
        if ($Version -like $pattern) {
            return $true
        }
    }

    return $false
}

function Get-GitHubRepoInfo
{
    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State
    )

    # Return the repository info from State
    if ($State.RepoOwner -and $State.RepoName) {
        return @{
            Owner = $State.RepoOwner
            Repo = $State.RepoName
            Url = "$($State.ServerUrl)/$($State.RepoOwner)/$($State.RepoName)"
        }
    }

    return $null
}

function Test-ReleaseImmutability
{
    param(
        [string]$Owner,
        [string]$Repo,
        [string]$Tag,
        [string]$Token,
        [string]$ApiUrl
    )

    try {
        # Use GitHub GraphQL API to check if release is immutable
        $headers = Get-ApiHeader -Token $Token

        # Construct the GraphQL query
        $query = @"
query(`$owner: String!, `$name: String!, `$tag: String!) {
  repository(owner: `$owner, name: `$name) {
    release(tagName: `$tag) {
      tagName
      isDraft
      immutable
    }
  }
}
"@


        $variables = @{
            owner = $Owner
            name = $Repo
            tag = $Tag
        }

        $body = @{
            query = $query
            variables = $variables
        } | ConvertTo-Json -Depth 10

        # Determine GraphQL endpoint from API URL
        $graphqlUrl = $ApiUrl -replace '/api/v3$', '/api/graphql'
        if ($graphqlUrl -eq $ApiUrl) {
            # Default to public GitHub GraphQL endpoint
            $graphqlUrl = "https://api.github.com/graphql"
        }

        $response = Invoke-WithRetry -OperationDescription "Release immutability check for $Tag" -ScriptBlock {
            if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                Invoke-WebRequestWrapper -Uri $graphqlUrl -Headers $headers -Method Post -Body $body -ContentType "application/json" -ErrorAction Stop -TimeoutSec 10
            } else {
                Invoke-RestMethod -Uri $graphqlUrl -Headers $headers -Method Post -Body $body -ContentType "application/json" -ErrorAction Stop
            }
        }

        if ($response -is [System.Collections.IDictionary] -and $response.ContainsKey('Content')) {
            $response = $response.Content | ConvertFrom-Json
        }

        # Check if we got a valid response
        if ($response.data.repository.release) {
            $release = $response.data.repository.release
            # Release is immutable if the GitHub API reports it as immutable
            return $release.immutable -eq $true
        }

        # No release found for this tag
        return $false
    }
    catch {
        Throw-GitHubApiFailure -Operation "release immutability check for $Tag" -ErrorRecord $_
    }
}

function Get-GitHubRelease {
    <#
    .SYNOPSIS
    Fetches all releases from a GitHub repository via the GraphQL API.

    .DESCRIPTION
    Uses the GitHub GraphQL API to fetch all releases with immutability and latest status.
    Returns ReleaseInfo objects directly, ready to add to RepositoryState.

    .PARAMETER State
    The RepositoryState object containing API configuration.

    .PARAMETER IgnoreVersions
    Optional array of version strings to mark as ignored.

    .OUTPUTS
    Returns an array of ReleaseInfo objects.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,

        [string[]]$IgnoreVersions = @()
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return @()
        }

        # Use GitHub GraphQL API to get releases with immutability status
        # This is more efficient than REST API + separate immutability checks
        $headers = Get-ApiHeader -Token $State.Token
        [ReleaseInfo[]]$allReleases = @()
        $cursor = $null

        # Determine GraphQL endpoint from API URL
        $graphqlUrl = $State.ApiUrl -replace '/api/v3$', '/api/graphql'
        if ($graphqlUrl -eq $State.ApiUrl) {
            # Default to public GitHub GraphQL endpoint
            $graphqlUrl = "https://api.github.com/graphql"
        }

        # Construct the GraphQL query
        $query = @"
query(`$owner: String!, `$name: String!, `$first: Int!, `$after: String) {
  repository(owner: `$owner, name: `$name) {
    releases(first: `$first, after: `$after, orderBy: {field: CREATED_AT, direction: DESC}) {
      pageInfo {
        hasNextPage
        endCursor
      }
      nodes {
        databaseId
        tagName
        isPrerelease
        isDraft
        immutable
        isLatest
      }
    }
  }
}
"@


        do {
            $variables = @{
                owner = $repoInfo.Owner
                name = $repoInfo.Repo
                first = 100
            }

            if ($cursor) {
                $variables['after'] = $cursor
            }

            $body = @{
                query = $query
                variables = $variables
            } | ConvertTo-Json -Depth 10

            # Use retry logic for transient failures
            $response = Invoke-WithRetry -OperationDescription "Get releases page" -ScriptBlock {
                # Use a wrapper to allow for test mocking
                if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                    Invoke-WebRequestWrapper -Uri $graphqlUrl -Headers $headers -Method Post -Body $body -ContentType "application/json" -ErrorAction Stop -TimeoutSec 10
                } else {
                    Invoke-RestMethod -Uri $graphqlUrl -Headers $headers -Method Post -Body $body -ContentType "application/json" -ErrorAction Stop
                }
            }

            # Handle response format (may be wrapped in Content property for Invoke-WebRequest)
            if ($response -is [System.Collections.IDictionary] -and $response.ContainsKey('Content')) {
                $response = $response.Content | ConvertFrom-Json
            }

            # Check for GraphQL errors
            if ($response.errors) {
                $errorMessages = ($response.errors | ForEach-Object { $_.message }) -join '; '
                throw "GraphQL errors: $errorMessages"
            }

            $releases = $response.data.repository.releases

            if (-not $releases -or -not $releases.nodes -or $releases.nodes.Count -eq 0) {
                break
            }

            # Collect releases - create ReleaseInfo objects directly
            foreach ($release in $releases.nodes) {
                # Build PSCustomObject in the format ReleaseInfo constructor expects
                $releaseData = [PSCustomObject]@{
                    tag_name = $release.tagName
                    id = $release.databaseId
                    draft = $release.isDraft
                    prerelease = $release.isPrerelease
                    html_url = $null  # Not available in GraphQL query
                    target_commitish = $null  # Not available in GraphQL query
                    immutable = $release.immutable
                    isLatest = $release.isLatest
                }

                $ri = [ReleaseInfo]::new($releaseData)

                # Check if this release's tag should be ignored (supports wildcards)
                $ri.IsIgnored = Test-VersionShouldBeIgnored -Version $ri.TagName -IgnoreVersions $IgnoreVersions

                $allReleases += $ri
            }

            # Check if there are more pages
            if ($releases.pageInfo.hasNextPage) {
                $cursor = $releases.pageInfo.endCursor
            } else {
                $cursor = $null
            }

        } while ($cursor)

        return $allReleases
    }
    catch {
        Throw-GitHubApiFailure -Operation "fetching releases" -ErrorRecord $_
    }
}

function Get-GitHubTag {
    <#
    .SYNOPSIS
    Fetches all tags from a GitHub repository via the REST API.

    .DESCRIPTION
    Uses the GitHub REST API to fetch all tags from the repository.
    This eliminates the need for a full clone with fetch-depth: 0 and fetch-tags: true.
    Returns VersionRef objects directly, ready to add to RepositoryState.

    .PARAMETER State
    The RepositoryState object containing API configuration.

    .PARAMETER Pattern
    Optional regex pattern to filter tags. If not specified, all tags are returned.

    .PARAMETER IgnoreVersions
    Optional array of version strings to mark as ignored.

    .OUTPUTS
    Returns an array of VersionRef objects.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,

        [string]$Pattern = $null,

        [string[]]$IgnoreVersions = @()
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            Write-Host "::debug::No repo info available for fetching tags"
            return @()
        }

        $headers = Get-ApiHeader -Token $State.Token
        $allTags = @()  # Untyped array for accumulating hashtables
        $url = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/git/refs/tags?per_page=100"

        do {
            $response = Invoke-WithRetry -OperationDescription "Get tags page" -ScriptBlock {
                if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                    Invoke-WebRequestWrapper -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
                } else {
                    Invoke-WebRequest -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
                }
            }

            $refs = $response.Content | ConvertFrom-Json

            # Handle case where response is a single object instead of array
            if ($refs -isnot [array]) {
                $refs = @($refs)
            }

            if ($refs.Count -eq 0) {
                break
            }

            foreach ($ref in $refs) {
                # refs/tags/v1.0.0 -> v1.0.0
                $tagName = $ref.ref -replace '^refs/tags/', ''

                # Apply pattern filter if specified
                if ($Pattern -and $tagName -notmatch $Pattern) {
                    continue
                }

                # Get the SHA - for annotated tags, we need to dereference
                $sha = $ref.object.sha

                # If this is an annotated tag (type = "tag"), we need to get the commit SHA
                if ($ref.object.type -eq "tag") {
                    try {
                        $tagResponse = Invoke-WithRetry -OperationDescription "Dereference tag $tagName" -ScriptBlock {
                            if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                                Invoke-WebRequestWrapper -Uri $ref.object.url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 5
                            } else {
                                Invoke-WebRequest -Uri $ref.object.url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 5
                            }
                        }
                        $tagObj = $tagResponse.Content | ConvertFrom-Json
                        $sha = $tagObj.object.sha
                    }
                    catch {
                        Throw-GitHubApiFailure -Operation "dereferencing annotated tag $tagName" -ErrorRecord $_
                    }
                }

                $allTags += @{
                    name = $tagName
                    sha = $sha
                }
            }

            # Check for Link header to get next page
            $linkHeader = $response.Headers['Link']
            $url = $null

            if ($linkHeader) {
                $links = $linkHeader -split ','
                foreach ($link in $links) {
                    if ($link -match '<([^>]+)>\s*;\s*rel="next"') {
                        $url = $matches[1]
                        break
                    }
                }
            }

        } while ($url)

        # Convert hashtables to VersionRef objects
        [VersionRef[]]$result = @()
        foreach ($tag in $allTags) {
            $vr = [VersionRef]::new($tag.name, "refs/tags/$($tag.name)", $tag.sha, "tag")

            # Check if this version should be ignored (supports wildcards)
            $vr.IsIgnored = Test-VersionShouldBeIgnored -Version $tag.name -IgnoreVersions $IgnoreVersions

            $result += $vr
        }

        return $result
    }
    catch {
        # Handle 404 gracefully - GitHub returns 404 when the repository has no tags
        # See: https://docs.github.com/en/rest/git/refs#get-all-references-in-a-namespace
        $statusCode = $null
        if ($_.Exception.Response) {
            $statusCode = $_.Exception.Response.StatusCode.value__
        }

        if ($statusCode -eq 404) {
            Write-Host "::debug::No tags found in repository (404 response - this is normal for repos with no tags)"
            return @()
        }

        Throw-GitHubApiFailure -Operation "fetching tags" -ErrorRecord $_
    }
}

function Get-GitHubBranch {
    <#
    .SYNOPSIS
    Fetches all branches from a GitHub repository via the REST API.

    .DESCRIPTION
    Uses the GitHub REST API to fetch all branches from the repository.
    This eliminates the need for a full clone.
    Returns VersionRef objects directly, ready to add to RepositoryState.

    .PARAMETER State
    The RepositoryState object containing API configuration.

    .PARAMETER Pattern
    Optional regex pattern to filter branches. If not specified, all branches are returned.

    .PARAMETER IgnoreVersions
    Optional array of version strings to mark as ignored.

    .OUTPUTS
    Returns an array of VersionRef objects.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,

        [string]$Pattern = $null,

        [string[]]$IgnoreVersions = @()
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            Write-Host "::debug::No repo info available for fetching branches"
            return @()
        }

        $headers = Get-ApiHeader -Token $State.Token
        $allBranches = @()
        $url = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/branches?per_page=100"

        do {
            $response = Invoke-WithRetry -OperationDescription "Get branches page" -ScriptBlock {
                if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                    Invoke-WebRequestWrapper -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
                } else {
                    Invoke-WebRequest -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
                }
            }

            $branches = $response.Content | ConvertFrom-Json

            if ($branches.Count -eq 0) {
                break
            }

            foreach ($branch in $branches) {
                # Apply pattern filter if specified
                if ($Pattern -and $branch.name -notmatch $Pattern) {
                    continue
                }

                $allBranches += @{
                    name = $branch.name
                    sha = $branch.commit.sha
                }
            }

            # Check for Link header to get next page
            $linkHeader = $response.Headers['Link']
            $url = $null

            if ($linkHeader) {
                $links = $linkHeader -split ','
                foreach ($link in $links) {
                    if ($link -match '<([^>]+)>\s*;\s*rel="next"') {
                        $url = $matches[1]
                        break
                    }
                }
            }

        } while ($url)

        # Convert hashtables to VersionRef objects
        [VersionRef[]]$result = @()
        foreach ($branch in $allBranches) {
            $vr = [VersionRef]::new($branch.name, "refs/heads/$($branch.name)", $branch.sha, "branch")

            # Check if this version should be ignored (supports wildcards)
            $vr.IsIgnored = Test-VersionShouldBeIgnored -Version $branch.name -IgnoreVersions $IgnoreVersions

            $result += $vr
        }

        return $result
    }
    catch {
        Throw-GitHubApiFailure -Operation "fetching branches" -ErrorRecord $_
    }
}

function Get-GitHubRef
{
    <#
    .SYNOPSIS
    Gets the SHA for a specific git reference (tag or branch) via the REST API.

    .DESCRIPTION
    Fetches the commit SHA for a specific reference. This is useful when you need
    the SHA for a single ref rather than fetching all refs.

    .PARAMETER State
    The RepositoryState object containing API configuration.

    .PARAMETER RefName
    The reference name (e.g., "v1.0.0" for a tag, "main" for a branch).

    .PARAMETER RefType
    The type of reference: "tags" or "heads" (for branches).

    .OUTPUTS
    Returns the commit SHA as a string, or $null if not found.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,

        [Parameter(Mandatory)]
        [string]$RefName,

        [Parameter(Mandatory)]
        [ValidateSet("tags", "heads")]
        [string]$RefType
    )

    try {
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return $null
        }

        $headers = Get-ApiHeader -Token $State.Token
        $url = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/git/refs/$RefType/$RefName"

        $response = Invoke-WithRetry -OperationDescription "Get ref $RefType/$RefName" -ScriptBlock {
            if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                Invoke-WebRequestWrapper -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 5
            } else {
                Invoke-WebRequest -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 5
            }
        }

        $ref = $response.Content | ConvertFrom-Json
        $sha = $ref.object.sha

        # If this is an annotated tag, dereference it
        if ($ref.object.type -eq "tag") {
            try {
                $tagResponse = Invoke-WithRetry -OperationDescription "Dereference ref $RefName" -ScriptBlock {
                    if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                        Invoke-WebRequestWrapper -Uri $ref.object.url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 5
                    } else {
                        Invoke-WebRequest -Uri $ref.object.url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 5
                    }
                }
                $tagObj = $tagResponse.Content | ConvertFrom-Json
                $sha = $tagObj.object.sha
            }
            catch {
                Throw-GitHubApiFailure -Operation "dereferencing annotated ref $RefName" -ErrorRecord $_
            }
        }

        return $sha
    }
    catch {
        $statusCode = $null
        if ($_.Exception -and $_.Exception.Response) {
            $statusCode = $_.Exception.Response.StatusCode.value__
        }

        if ($statusCode -eq 404) {
            Write-Host "::debug::Ref $RefType/$RefName not found"
            return $null
        }

        Throw-GitHubApiFailure -Operation "fetching ref $RefType/$RefName" -ErrorRecord $_
    }
}

function Test-ImmutableReleaseError
{
    <#
    .SYNOPSIS
    Check if an error is a 422 error indicating a tag was used by an immutable release.

    .DESCRIPTION
    When a release is deleted but was immutable, GitHub prevents creating/updating
    releases with the same tag. This function checks if an error matches this condition.

    .PARAMETER ErrorRecord
    The error record from a catch block ($_).

    .OUTPUTS
    Returns $true if this is an immutable release conflict error, $false otherwise.
    #>

    param(
        [Parameter(Mandatory)]
        $ErrorRecord
    )

    # Check status code
    $statusCode = $null
    if ($ErrorRecord.Exception.Response) {
        $statusCode = $ErrorRecord.Exception.Response.StatusCode.value__
    }

    # Must be a 422 error
    if ($statusCode -ne 422 -and $ErrorRecord.Exception.Message -notmatch "422") {
        return $false
    }

    # Try to parse the error details JSON
    if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
        try {
            $errorData = $ErrorRecord.ErrorDetails.Message | ConvertFrom-Json

            # Check if errors array contains the immutable release message
            if ($errorData.errors) {
                foreach ($err in $errorData.errors) {
                    if ($err.field -eq "tag_name" -and $err.message -match "was used by an immutable release") {
                        return $true
                    }
                }
                # None of the structured errors matched, so this is not an immutable release error
                return $false
            }
        }
        catch {
            # If JSON parsing fails, fall back to string matching
            # This is intentional - we continue to the fallback check below
            $null = $null  # Suppress PSSA warning for empty catch
        }
    }

    # Fallback: check the exception message directly (only if ErrorDetails parsing failed or unavailable)
    return $ErrorRecord.Exception.Message -match "tag_name was used by an immutable release"
}

function Remove-GitHubRelease
{
    <#
    .SYNOPSIS
    Deletes a GitHub release via the REST API.

    .PARAMETER State
    The RepositoryState object containing API configuration.

    .PARAMETER TagName
    The tag name associated with the release.

    .PARAMETER ReleaseId
    Optional. The release ID. If not provided, will be looked up by tag name.

    .OUTPUTS
    Returns $true if deletion succeeded, $false otherwise.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,
        [string]$TagName,
        [int]$ReleaseId = 0
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return $false
        }

        $headers = Get-ApiHeader -Token $State.Token
        $releaseIdToDelete = $ReleaseId

        # If ReleaseId not provided, look it up by tag name
        # This handles both draft releases (which may not have tags yet) and regular releases
        if ($releaseIdToDelete -eq 0) {
            # Try to find the release ID from State.Releases first (more reliable for drafts)
            $releaseFromState = $State.Releases | Where-Object { $_.TagName -eq $TagName } | Select-Object -First 1
            if ($releaseFromState) {
                $releaseIdToDelete = $releaseFromState.Id
                Write-Host "::debug::Found release ID $releaseIdToDelete for $TagName from State"
            } else {
                # Fall back to API lookup by tag name (may fail for draft releases without tags)
                Write-Host "::debug::Looking up release by tag name: $TagName"
                $url = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/releases/tags/$TagName"

                $response = Invoke-WithRetry -OperationDescription "Get release $TagName" -ScriptBlock {
                    if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                        Invoke-WebRequestWrapper -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 5
                    } else {
                        Invoke-WebRequest -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 5
                    }
                }
                $release = $response.Content | ConvertFrom-Json
                $releaseIdToDelete = $release.id
            }
        }

        # Delete the release using the ID
        $deleteUrl = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/releases/$releaseIdToDelete"
        Write-Host "::debug::Deleting release ID $releaseIdToDelete for $TagName"

        Invoke-WithRetry -OperationDescription "Delete release $TagName" -ScriptBlock {
            if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                $null = Invoke-WebRequestWrapper -Uri $deleteUrl -Headers $headers -Method Delete -ErrorAction Stop -TimeoutSec 5
            } else {
                $null = Invoke-WebRequest -Uri $deleteUrl -Headers $headers -Method Delete -ErrorAction Stop -TimeoutSec 5
            }
        }

        return $true
    }
    catch {
        # Wrap exception message in stop-commands to prevent workflow command injection
        Write-SafeOutput -Message ([string]$_) -Prefix "::debug::Failed to delete release for $TagName (ID: $ReleaseId) : "
        return $false
    }
}

function New-GitHubRelease
{
    <#
    .SYNOPSIS
    Create a GitHub release (draft or published).

    .DESCRIPTION
    Creates a GitHub release for the specified tag. Can create either a draft release
    or a published release based on the Draft parameter.

    .PARAMETER State
    The repository state object containing API configuration.

    .PARAMETER TagName
    The tag name for the release.

    .PARAMETER Draft
    If true, creates a draft release. If false, creates a published release. Defaults to true.

    .PARAMETER MakeLatest
    Controls whether this release should be marked as "latest".
    - $true: Force this release to be latest
    - $false: Prevent this release from becoming latest
    - $null: Let GitHub determine based on version (default behavior)

    .OUTPUTS
    A hashtable with Success (bool), ReleaseId (int or null), and Unfixable (bool) properties.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,
        [string]$TagName,
        [bool]$Draft = $true,
        [Parameter(Mandatory = $false)]
        $MakeLatest = $null
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return @{ Success = $false; ReleaseId = $null; Unfixable = $false }
        }

        # Create a release (draft or published based on parameter)
        $headers = Get-ApiHeader -Token $State.Token
        $url = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/releases"

        $bodyObj = @{
            tag_name = $TagName
            name = $TagName
            body = "Release $TagName"
            draft = $Draft
        }

        # Add make_latest if explicitly specified
        if ($null -ne $MakeLatest) {
            $bodyObj['make_latest'] = if ($MakeLatest) { 'true' } else { 'false' }
        }

        $body = $bodyObj | ConvertTo-Json

        $releaseObj = Invoke-GitHubHttpRequest -Uri $url -Headers $headers -Method Post -Body $body -ContentType "application/json" -TimeoutSec 10 -OperationDescription "Create release $TagName" -ParseJsonContent

        # Return success with the release ID
        return @{ Success = $true; ReleaseId = $releaseObj.id; Unfixable = $false }
    }
    catch {
        $errorMessage = $_.Exception.Message
        $isUnfixable = Test-ImmutableReleaseError -ErrorRecord $_

        if ($isUnfixable) {
            Write-SafeOutput -Message $errorMessage -Prefix "::debug::Unfixable error - tag used by immutable release for $TagName : "
        } else {
            Write-SafeOutput -Message $errorMessage -Prefix "::debug::Failed to create release for $TagName : "
        }

        return @{ Success = $false; ReleaseId = $null; Unfixable = $isUnfixable }
    }
}

function Publish-GitHubRelease {
    <#
    .SYNOPSIS
    Publish a draft GitHub release.

    .DESCRIPTION
    Publishes a draft release by setting draft to false. Optionally controls whether
    the release should be marked as "latest".

    .PARAMETER State
    The repository state object containing API configuration.

    .PARAMETER TagName
    The tag name for the release.

    .PARAMETER ReleaseId
    The release ID. If not provided, will be looked up by tag name.

    .PARAMETER MakeLatest
    Controls whether this release should be marked as "latest".
    - $true: Force this release to be latest
    - $false: Prevent this release from becoming latest
    - $null: Let GitHub determine based on version (default behavior)

    .OUTPUTS
    A hashtable with Success (bool) and Unfixable (bool) properties.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,
        [string]$TagName,
        [Parameter(Mandatory = $false)]
        [int]$ReleaseId,
        [Parameter(Mandatory = $false)]
        $MakeLatest = $null
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return @{ Success = $false; Unfixable = $false }
        }

        $headers = Get-ApiHeader -Token $State.Token

        # If ReleaseId is not provided, fetch it by tag name
        if (-not $ReleaseId) {
            $releasesUrl = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/releases/tags/$TagName"

            $releaseResponse = Invoke-GitHubHttpRequest -Uri $releasesUrl -Headers $headers -Method Get -TimeoutSec 10 -OperationDescription "Get release $TagName" -ParseJsonContent

            $ReleaseId = $releaseResponse.id
        }

        # Update the release to publish it (set draft to false)
        $updateUrl = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/releases/$ReleaseId"
        $bodyObj = @{
            draft = $false
        }

        # Add make_latest if explicitly specified
        if ($null -ne $MakeLatest) {
            $bodyObj['make_latest'] = if ($MakeLatest) { 'true' } else { 'false' }
        }

        $body = $bodyObj | ConvertTo-Json

        $null = Invoke-GitHubHttpRequest -Uri $updateUrl -Headers $headers -Method Patch -Body $body -ContentType "application/json" -TimeoutSec 10 -OperationDescription "Publish release $TagName"

        return @{ Success = $true; Unfixable = $false }
    }
    catch {
        $errorMessage = $_.Exception.Message
        $isUnfixable = Test-ImmutableReleaseError -ErrorRecord $_

        if ($isUnfixable) {
            Write-SafeOutput -Message $errorMessage -Prefix "::debug::Unfixable error - tag used by immutable release for $TagName : "
        } else {
            Write-SafeOutput -Message $errorMessage -Prefix "::debug::Failed to publish release for $TagName : "
        }

        return @{ Success = $false; Unfixable = $isUnfixable }
    }
}

function Set-GitHubReleaseLatest
{
    <#
    .SYNOPSIS
    Set a release as the "latest" release in GitHub.

    .DESCRIPTION
    Updates a release to be marked as the "latest" release using the make_latest
    parameter. This is used when the wrong release is currently marked as latest.

    .PARAMETER State
    The repository state object containing API configuration.

    .PARAMETER TagName
    The tag name for the release.

    .PARAMETER ReleaseId
    The release ID.

    .OUTPUTS
    A hashtable with Success (bool) and Unfixable (bool) properties.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,
        [Parameter(Mandatory)]
        [string]$TagName,
        [Parameter(Mandatory)]
        [int]$ReleaseId
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return @{ Success = $false; Unfixable = $false }
        }

        $headers = Get-ApiHeader -Token $State.Token

        # Update the release to set it as latest
        $updateUrl = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/releases/$ReleaseId"
        $body = @{
            make_latest = 'true'
        } | ConvertTo-Json

        $null = Invoke-GitHubHttpRequest -Uri $updateUrl -Headers $headers -Method Patch -Body $body -ContentType "application/json" -TimeoutSec 10 -OperationDescription "Set release $TagName as latest"

        return @{ Success = $true; Unfixable = $false }
    }
    catch {
        $errorMessage = $_.Exception.Message
        $isUnfixable = Test-ImmutableReleaseError -ErrorRecord $_

        if ($isUnfixable) {
            Write-SafeOutput -Message $errorMessage -Prefix "::debug::Unfixable error when setting $TagName as latest: "
        } else {
            Write-SafeOutput -Message $errorMessage -Prefix "::debug::Failed to set release $TagName as latest: "
        }

        return @{ Success = $false; Unfixable = $isUnfixable }
    }
}

function Republish-GitHubRelease
{
    <#
    .SYNOPSIS
    Republish a release to make it immutable.

    .DESCRIPTION
    When immutable releases are enabled for a repository, existing releases
    are not automatically made immutable. This function converts a mutable
    release to immutable by temporarily making it a draft, then publishing it again.

    This only works for patch versions (vX.Y.Z) and will fail if the release
    is already immutable.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,
        [Parameter(Mandatory)]
        [string]$TagName,
        [Parameter(Mandatory = $false)]
        $MakeLatest = $null
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return @{ Success = $false; Reason = "No repo info available"; Unfixable = $false }
        }

        $headers = Get-ApiHeader -Token $State.Token

        # Step 1: Get the release by tag name
        $releasesUrl = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/releases/tags/$TagName"

        $releaseResponse = Invoke-WithRetry -OperationDescription "Get release $TagName" -ScriptBlock {
            if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                Invoke-WebRequestWrapper -Uri $releasesUrl -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
            } else {
                Invoke-RestMethod -Uri $releasesUrl -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
            }
        }

        $releaseId = $releaseResponse.id
        $isDraft = $releaseResponse.draft

        # Step 2: Check if already immutable
        $isImmutable = Test-ReleaseImmutability -Owner $repoInfo.Owner -Repo $repoInfo.Repo -Tag $TagName -Token $State.Token -ApiUrl $State.ApiUrl

        if ($isImmutable) {
            Write-Host "::debug::Release $TagName is already immutable, skipping"
            return @{ Success = $true; Reason = "Already immutable"; Unfixable = $false }
        }

        # If not already in draft, make it a draft first
        if (-not $isDraft) {
            Write-Host "::debug::Converting release $TagName to draft"
            $updateUrl = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/releases/$releaseId"
            $draftBody = @{
                draft = $true
            } | ConvertTo-Json

            Invoke-WithRetry -OperationDescription "Convert release $TagName to draft" -ScriptBlock {
                if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                    Invoke-WebRequestWrapper -Uri $updateUrl -Headers $headers -Method Patch -Body $draftBody -ContentType "application/json" -ErrorAction Stop -TimeoutSec 10
                } else {
                    Invoke-RestMethod -Uri $updateUrl -Headers $headers -Method Patch -Body $draftBody -ContentType "application/json" -ErrorAction Stop -TimeoutSec 10
                }
            }
        }

        # Step 3: Publish the release to make it immutable
        Write-Host "::debug::Publishing release $TagName to make it immutable (makeLatest=$MakeLatest)"
        $publishResult = Publish-GitHubRelease -State $State -TagName $TagName -ReleaseId $releaseId -MakeLatest $MakeLatest

        if ($publishResult.Success) {
            return @{ Success = $true; Reason = "Republished successfully"; Unfixable = $false }
        } else {
            # Propagate the unfixable status from Publish-GitHubRelease
            $isUnfixable = $publishResult.ContainsKey('Unfixable') -and $publishResult.Unfixable
            return @{ Success = $false; Reason = "Failed to publish"; Unfixable = $isUnfixable }
        }
    }
    catch {
        $errorMessage = $_.Exception.Message
        Write-SafeOutput -Message $errorMessage -Prefix "::debug::Failed to republish release for $TagName : "
        return @{ Success = $false; Reason = $errorMessage; Unfixable = $false }
    }
}

function New-GitHubRef
{
    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,
        [string]$RefName,  # e.g., "refs/tags/v1.0.0" or "refs/heads/main"
        [string]$Sha,
        [bool]$Force = $true  # Force update if ref exists
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return @{ Success = $false; RequiresManualFix = $false; ErrorOutput = "No repo info available" }
        }

        $headers = Get-ApiHeader -Token $State.Token

        # Try to update the ref first (in case it exists)
        $updateUrl = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/git/$RefName"
        $body = @{
            sha = $Sha
            force = $Force
        } | ConvertTo-Json

        try {
            $null = Invoke-GitHubHttpRequest -Uri $updateUrl -Headers $headers -Method Patch -Body $body -ContentType "application/json" -TimeoutSec 10 -OperationDescription "Update ref $RefName"
            return @{ Success = $true; RequiresManualFix = $false }
        }
        catch {
            # Check if this is a 404 error (ref doesn't exist)
            $is404 = $false
            if ($_.Exception.Response) {
                $statusCode = $_.Exception.Response.StatusCode.value__
                $is404 = ($statusCode -eq 404)
            }

            # Only try to create if the ref doesn't exist (404 error)
            if ($is404) {
                $createUrl = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/git/refs"
                $createBody = @{
                    ref = $RefName
                    sha = $Sha
                } | ConvertTo-Json

                $null = Invoke-GitHubHttpRequest -Uri $createUrl -Headers $headers -Method Post -Body $createBody -ContentType "application/json" -TimeoutSec 10 -OperationDescription "Create ref $RefName"
                return @{ Success = $true; RequiresManualFix = $false }
            }
            else {
                # Re-throw the error if it's not a 404
                throw
            }
        }
    }
    catch {
        # Extract detailed error information
        $errorMessage = $_.Exception.Message
        $statusCode = 0

        if ($_.Exception.Response) {
            $statusCode = $_.Exception.Response.StatusCode.value__
        }

        # Check for permission errors (403 Forbidden)
        if ($statusCode -eq 403) {
            $gitRoot = Join-Path (Get-Location) ".git"
            $gitAvailable = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
            $hasGitRepo = Test-Path $gitRoot

            if ($env:GITHUB_API_ALLOW_GIT_FALLBACK -ne 'true') {
                Write-Host "::debug::REST API returned 403 for $RefName, git fallback disabled"
                return @{ Success = $false; RequiresManualFix = $true; ErrorOutput = "REST API returned 403 for $RefName and git fallback is disabled." }
            }

            if (-not $gitAvailable -or -not $hasGitRepo) {
                Write-Host "::debug::REST API returned 403 for $RefName, git fallback unavailable"
                return @{ Success = $false; RequiresManualFix = $true; ErrorOutput = "REST API returned 403 for $RefName and git fallback is unavailable." }
            }

            Write-Host "::debug::REST API returned 403 for $RefName, falling back to git push"

            # Fall back to using git push since REST API doesn't have permission
            # Extract tag/branch name from RefName (e.g., "refs/tags/v1.0.0" -> "v1.0.0")

            try {
                # Use git push to create/update the ref
                if ($Force) {
                    $gitCmd = "git push origin $Sha`:$RefName --force"
                } else {
                    $gitCmd = "git push origin $Sha`:$RefName"
                }

                Write-Host "::debug::Executing fallback: $gitCmd"

                # Execute git push
                $output = & git push origin "$Sha`:$RefName" $(if ($Force) { '--force' }) 2>&1
                $exitCode = $LASTEXITCODE

                if ($exitCode -eq 0) {
                    Write-Host "::debug::Successfully created/updated $RefName via git push"
                    return @{ Success = $true; RequiresManualFix = $false }
                } else {
                    # Check if error is due to workflows permission
                    $outputStr = [string]$output
                    $requiresWorkflowsPermission = $outputStr -match "refusing to allow a GitHub App to create or update workflow" -and $outputStr -match "without `[`"'`]?workflows`[`"'`]? permission"

                    Write-Host "::error::Git push failed for $RefName"
                    # Log the detailed error at debug level to avoid cluttering error output
                    Write-SafeOutput -Message $outputStr -Prefix "::debug::Git push error details: "

                    return @{
                        Success = $false
                        RequiresManualFix = $requiresWorkflowsPermission
                        ErrorOutput = $outputStr
                    }
                }
            }
            catch {
                Write-Host "::error::Failed to push $RefName via git"
                Write-SafeOutput -Message ([string]$_) -Prefix "::debug::Git error details: "
                return @{ Success = $false; RequiresManualFix = $false; ErrorOutput = [string]$_ }
            }
        } else {
            Write-SafeOutput -Message $errorMessage -Prefix "::debug::Failed to create/update ref $RefName : "
        }

        return @{ Success = $false; RequiresManualFix = $false; ErrorOutput = $errorMessage }
    }
}

function Get-GitHubFileContents {
    <#
    .SYNOPSIS
    Fetches the contents of a file from a GitHub repository.

    .PARAMETER State
    The RepositoryState object containing API configuration.

    .PARAMETER Path
    The path to the file in the repository (e.g., "action.yaml" or "README.md").

    .PARAMETER Ref
    Optional. The commit, branch, or tag to get the file from. Defaults to the default branch.

    .OUTPUTS
    Returns the file content as a string, or $null if the file doesn't exist.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,

        [Parameter(Mandatory)]
        [string]$Path,

        [string]$Ref
    )

    try {
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return $null
        }

        $headers = Get-ApiHeader -Token $State.Token
        $url = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/contents/$Path"

        if ($Ref) {
            $url += "?ref=$Ref"
        }

        $response = Invoke-WithRetry -OperationDescription "Fetch file $Path" -ScriptBlock {
            if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                Invoke-WebRequestWrapper -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
            } else {
                Invoke-WebRequest -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
            }
        }

        $content = $response.Content | ConvertFrom-Json

        # GitHub returns base64-encoded content for files
        if ($content.encoding -eq 'base64' -and $content.content) {
            $decodedContent = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($content.content))
            return $decodedContent
        }

        return $null
    }
    catch {
        $statusCode = $null
        if ($_.Exception -and $_.Exception.Response) {
            $statusCode = $_.Exception.Response.StatusCode.value__
        }

        if ($statusCode -eq 404) {
            Write-Host "::debug::File $Path not found in repository"
            return $null
        }

        Throw-GitHubApiFailure -Operation "fetching file $Path" -ErrorRecord $_
    }
}

function Test-GitHubFileExists {
    <#
    .SYNOPSIS
    Checks if a file exists in a GitHub repository without fetching its contents.

    .PARAMETER State
    The RepositoryState object containing API configuration.

    .PARAMETER Path
    The path to the file in the repository.

    .PARAMETER Ref
    Optional. The commit, branch, or tag to check. Defaults to the default branch.

    .OUTPUTS
    Returns $true if the file exists, $false otherwise.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,

        [Parameter(Mandatory)]
        [string]$Path,

        [string]$Ref
    )

    try {
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return $false
        }

        $headers = Get-ApiHeader -Token $State.Token
        $url = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/contents/$Path"

        if ($Ref) {
            $url += "?ref=$Ref"
        }

        # Use HEAD request to check existence without fetching content
        $response = Invoke-WithRetry -OperationDescription "Check file $Path exists" -ScriptBlock {
            if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                Invoke-WebRequestWrapper -Uri $url -Headers $headers -Method Head -ErrorAction Stop -TimeoutSec 10
            } else {
                Invoke-WebRequest -Uri $url -Headers $headers -Method Head -ErrorAction Stop -TimeoutSec 10
            }
        }

        return $response.StatusCode -eq 200
    }
    catch {
        $statusCode = $null
        if ($_.Exception -and $_.Exception.Response) {
            $statusCode = $_.Exception.Response.StatusCode.value__
        }

        if ($statusCode -eq 404) {
            return $false
        }

        Write-Host "::debug::Error checking file $Path exists: $_"
        return $false
    }
}

function Get-GitHubDirectoryContents {
    <#
    .SYNOPSIS
    Lists the contents of a directory in a GitHub repository.

    .DESCRIPTION
    Uses the GitHub Contents API to list files and subdirectories in a given path.
    This is more efficient than checking individual files when you need to find
    one of several possible filenames (e.g., README.md with different cases).

    .PARAMETER State
    The RepositoryState object containing API configuration.

    .PARAMETER Path
    The directory path in the repository. Use empty string or "/" for root.

    .PARAMETER Ref
    Optional. The commit, branch, or tag to list from. Defaults to the default branch.

    .OUTPUTS
    Returns an array of objects with Name, Path, Type (file/dir), and Sha properties.
    Returns empty array if the directory doesn't exist.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,

        [string]$Path = "",

        [string]$Ref
    )

    try {
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return @()
        }

        $headers = Get-ApiHeader -Token $State.Token
        $url = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/contents"

        if ($Path -and $Path -ne "/" -and $Path -ne ".") {
            $url += "/$Path"
        }

        if ($Ref) {
            $url += "?ref=$Ref"
        }

        $response = Invoke-WithRetry -OperationDescription "List directory $Path" -ScriptBlock {
            if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                Invoke-WebRequestWrapper -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
            } else {
                Invoke-WebRequest -Uri $url -Headers $headers -Method Get -ErrorAction Stop -TimeoutSec 10
            }
        }

        $content = $response.Content | ConvertFrom-Json

        # Ensure we have an array (single file returns object, directory returns array)
        if ($content -isnot [array]) {
            # Single item returned - might be a file, not a directory
            if ($content.type -eq 'file') {
                return @()
            }
            $content = @($content)
        }

        # Map to simplified objects
        $results = $content | ForEach-Object {
            [PSCustomObject]@{
                Name = $_.name
                Path = $_.path
                Type = $_.type
                Sha  = $_.sha
            }
        }

        return $results
    }
    catch {
        $statusCode = $null
        if ($_.Exception -and $_.Exception.Response) {
            $statusCode = $_.Exception.Response.StatusCode.value__
        }

        if ($statusCode -eq 404) {
            Write-Host "::debug::Directory $Path not found in repository"
            return @()
        }

        Write-Host "::debug::Error listing directory $Path : $_"
        return @()
    }
}

function Remove-GitHubRef
{
    <#
    .SYNOPSIS
    Deletes a git reference (tag or branch) via the GitHub REST API.

    .PARAMETER State
    The RepositoryState object containing API configuration.

    .PARAMETER RefName
    The full reference name (e.g., "refs/tags/v1.0.0" or "refs/heads/main").

    .OUTPUTS
    Returns $true if deletion succeeded, $false otherwise.
    #>

    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,
        [string]$RefName  # e.g., "refs/tags/v1.0.0" or "refs/heads/main"
    )

    try {
        # Get repo info from State
        $repoInfo = Get-GitHubRepoInfo -State $State
        if (-not $repoInfo) {
            return $false
        }

        $headers = Get-ApiHeader -Token $State.Token
        $url = "$($State.ApiUrl)/repos/$($repoInfo.Owner)/$($repoInfo.Repo)/git/$RefName"

        Invoke-WithRetry -OperationDescription "Delete ref $RefName" -ScriptBlock {
            if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
                $null = Invoke-WebRequestWrapper -Uri $url -Headers $headers -Method Delete -ErrorAction Stop -TimeoutSec 10
            } else {
                $null = Invoke-RestMethod -Uri $url -Headers $headers -Method Delete -ErrorAction Stop -TimeoutSec 10
            }
        }

        return $true
    }
    catch {
        Write-SafeOutput -Message ([string]$_) -Prefix "::debug::Failed to delete ref $RefName : "
        return $false
    }
}