SessionIdentity.ps1

# Discovery-only recovery. These helpers never persist an inferred identity.
function Test-CopilotRepositoryCompatibility {
    param([string]$Recorded, [string]$Canonical)

    if (-not $Recorded -or $Recorded -ieq $Canonical) { return $true }
    $parts = $Canonical.Split('/')
    if ($parts.Count -ne 3) { return $false }
    return ($Recorded -ieq "$($parts[1])/$($parts[2])" -or
        $Recorded -ieq "DefaultCollection/$($parts[1])/$($parts[2])")
}

function Get-CopilotLifecycleContext {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$SessionPath)

    $journal = Join-Path $SessionPath 'events.jsonl'
    if (-not (Test-Path -LiteralPath $journal -PathType Leaf -ErrorAction Stop)) { return }
    if (-not ('Shmuelie.Copilot.SessionIdentityJournal' -as [type])) {
        Add-Type -Path (Join-Path $PSScriptRoot 'Classes' 'SessionIdentityJournal.cs') -ErrorAction Stop
    }
    $snapshot = Get-Item -LiteralPath $journal -ErrorAction Stop
    $length = $snapshot.Length
    $modified = $snapshot.LastWriteTimeUtc
    $latest = $null
    $records = [Shmuelie.Copilot.SessionIdentityJournal]::Read($journal)
    try {
        while ($records.MoveNext()) {
            $record = $records.Current
            if ($record.Incomplete) {
                throw [IO.EndOfStreamException]::new('Identity recovery journal has an incomplete trailing record.')
            }
            $event = ConvertFrom-Json -InputObject $record.Json -AsHashtable -ErrorAction Stop
            if ($event.data -isnot [System.Collections.IDictionary]) {
                throw [FormatException]::new('Lifecycle data must be an object.')
            }
            if ($event.data.sessionId -and $event.data.sessionId -cne (Split-Path $SessionPath -Leaf)) {
                throw [FormatException]::new('Lifecycle sessionId does not match the session directory.')
            }
            # A later lifecycle boundary without context invalidates old evidence.
            $latest = $event.data.context
            if ($null -ne $latest -and $latest -isnot [System.Collections.IDictionary]) {
                throw [FormatException]::new('Lifecycle context must be an object.')
            }
        }
    } finally {
        $records.Dispose()
    }
    $snapshot.Refresh()
    if (-not $snapshot.Exists -or $snapshot.Length -ne $length -or $snapshot.LastWriteTimeUtc -ne $modified) {
        throw [IO.EndOfStreamException]::new('Identity recovery journal changed while being read.')
    }
    if ($latest) {
        foreach ($field in 'repository', 'branch', 'cwd', 'gitRoot', 'hostType', 'repositoryHost') {
            if ($null -ne $latest[$field] -and $latest[$field] -isnot [string]) {
                throw [FormatException]::new("Lifecycle context $field must be a string.")
            }
        }
        return $latest
    }
}

function Get-CopilotRecordedOriginIdentity {
    param([Parameter(Mandatory)][string]$Path)

    if (-not [IO.Path]::IsPathFullyQualified($Path) -or
        -not (Test-Path -LiteralPath $Path -PathType Container -ErrorAction Stop)) { return }
    $previousExitCode = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction Ignore
    $previousExitCodeValue = if ($previousExitCode) { $previousExitCode.Value }
    $PSNativeCommandUseErrorActionPreference = $false
    try {
        $global:LASTEXITCODE = $null
        $inside = @(git -C $Path rev-parse --is-inside-work-tree 2>$null)
        if ($LASTEXITCODE -ne 0 -or $inside.Count -ne 1 -or $inside[0] -cne 'true') { return }
        $global:LASTEXITCODE = $null
        $urls = @(git -C $Path config --get-all remote.origin.url 2>$null)
        if ($LASTEXITCODE -ne 0 -or $urls.Count -ne 1) {
            throw [FormatException]::new('Recorded Git path does not have one origin.')
        }
        $identity = ConvertTo-CopilotAdoRepositoryIdentity -Url $urls[0]
        if (-not $identity) { $identity = ConvertTo-CopilotGitHubRepositoryIdentity -Url $urls[0] }
        if (-not $identity) {
            throw [FormatException]::new('Recorded Git origin is not recognized.')
        }
        return $identity
    } finally {
        if ($previousExitCode) { $global:LASTEXITCODE = $previousExitCodeValue }
        else { Remove-Variable -Name LASTEXITCODE -Scope Global -WhatIf:$false -Confirm:$false }
    }
}

function Resolve-CopilotSessionIdentity {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SessionPath,
        [string]$Cwd,
        [string]$Repository,
        [string]$Branch,
        $Scope
    )

    $recorded = [pscustomobject]@{ Cwd = $Cwd; Repository = $Repository; Branch = $Branch }
    if (-not $Scope.Repository -or -not $Scope.Branch) { return $recorded }
    $adoScope = $Scope.Repository -like '*/*/*'
    # Current scope may rule evidence out, but never supplies missing identity.
    if (($Branch -and $Branch -cne $Scope.Branch) -or
        ($Repository -and -not (Test-CopilotRepositoryCompatibility -Recorded $Repository -Canonical $Scope.Repository))) {
        return $recorded
    }
    # Complete ordinary identities need no journal or historical worktree reads.
    $legacy = $adoScope -and ($Repository.Split('/').Count -eq 2 -or $Repository -like 'DefaultCollection/*')
    if ($Repository -and $Branch -and -not $legacy) { return $recorded }

    try {
        $context = Get-CopilotLifecycleContext -SessionPath $SessionPath
        $effectiveRepository = $Repository
        $effectiveBranch = $Branch
        $effectiveCwd = $Cwd
        $paths = [System.Collections.Generic.List[string]]::new()
        if ($Cwd) { $paths.Add($Cwd) }
        if ($context) {
            # Never borrow context from a different known directory or branch.
            $contextPaths = @($context.cwd, $context.gitRoot | Where-Object { $_ })
            if (($Cwd -and $contextPaths.Count -gt 0 -and $Cwd -notin $contextPaths) -or
                ($Branch -and $context.branch -and $context.branch -cne $Branch)) {
                return $recorded
            }
            $contextRepository = $context.repository
            $organization = $null
            if ($context.repositoryHost) {
                if ($context.repositoryHost -match '\A([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.visualstudio\.com\z') {
                    $organization = $Matches[1].ToLowerInvariant()
                } elseif ($context.repositoryHost -notin @('dev.azure.com', 'ssh.dev.azure.com', 'github.com')) {
                    throw [FormatException]::new('Lifecycle repositoryHost is not a supported ADO hostname.')
                }
                if (($context.repositoryHost -ieq 'github.com' -and $adoScope) -or
                    ($context.repositoryHost -ine 'github.com' -and $context.hostType -and
                        $context.hostType -notin @('ado', 'azure-devops', 'azureDevOps'))) {
                    return $recorded
                }
            }
            if ($context.hostType -and $context.hostType -notin @('ado', 'azure-devops', 'azureDevOps', 'github')) {
                return $recorded
            }
            if ($context.hostType -ieq 'github' -and $adoScope) { return $recorded }
            if ($organization) {
                if ($contextRepository) {
                    $parts = $contextRepository.Split('/')
                    if ($parts.Count -eq 2) { $contextRepository = "$organization/$contextRepository" }
                    elseif ($parts.Count -eq 3 -and $parts[0] -ieq 'DefaultCollection') {
                        $contextRepository = "$organization/$($parts[1])/$($parts[2])"
                    } elseif ($parts.Count -ne 3 -or $parts[0] -ine $organization) {
                        return $recorded
                    }
                } elseif ($legacy) {
                    $parts = $Repository.Split('/')
                    $contextRepository = "$organization/$($parts[-2])/$($parts[-1])"
                }
            }
            if ($contextRepository) {
                # Validate decoded canonical ADO identities with the URL parser.
                $parts = $contextRepository.Split('/')
                $canonical = if ($parts.Count -eq 3 -and $parts[0] -ine 'DefaultCollection') {
                    $urlParts = @($parts | ForEach-Object { [uri]::EscapeDataString($_) })
                    ConvertTo-CopilotAdoRepositoryIdentity -Url "https://dev.azure.com/$($urlParts[0])/$($urlParts[1])/_git/$($urlParts[2])"
                } elseif (-not $adoScope -and $parts.Count -eq 2) {
                    ConvertTo-CopilotGitHubRepositoryIdentity -Url "https://github.com/$contextRepository"
                }
                if ($canonical) {
                    if (-not (Test-CopilotRepositoryCompatibility -Recorded $Repository -Canonical $canonical)) { return $recorded }
                    $effectiveRepository = $canonical
                } elseif ($adoScope -and ($parts.Count -eq 2 -or
                    ($parts.Count -eq 3 -and $parts[0] -ieq 'DefaultCollection'))) {
                    # Keep the unresolved shape pending independent Git evidence.
                    if ($Repository -and $Repository -ine $contextRepository -and
                        -not (Test-CopilotRepositoryCompatibility -Recorded $contextRepository -Canonical $Repository)) { return $recorded }
                    if (-not $Repository) { $effectiveRepository = $contextRepository }
                } else { return $recorded }
            }
            if (-not $Branch) { $effectiveBranch = $context.branch }
            if (-not $Cwd) { $effectiveCwd = $context.cwd }
            foreach ($path in $context.cwd, $context.gitRoot) {
                if ($path -and $path -notin $paths) { $paths.Add($path) }
            }
        }

        # A complete lifecycle recovery needs no Git probe. Otherwise only
        # recorded paths can establish origin; never read today's branch.
        $stillLegacy = $adoScope -and ($effectiveRepository.Split('/').Count -eq 2 -or $effectiveRepository -like 'DefaultCollection/*')
        if (-not $effectiveRepository -or $stillLegacy) {
            $origins = @($paths | ForEach-Object { Get-CopilotRecordedOriginIdentity -Path $_ } | Sort-Object -Unique)
            if ($origins.Count -gt 1) { return $recorded }
            if ($origins.Count -eq 1) {
                if (-not (Test-CopilotRepositoryCompatibility -Recorded $effectiveRepository -Canonical $origins[0])) { return $recorded }
                $effectiveRepository = $origins[0]
            }
        }
        [pscustomobject]@{ Cwd = $effectiveCwd; Repository = $effectiveRepository; Branch = $effectiveBranch }
    } catch {
        if ($_.Exception.GetBaseException() -is [IO.EndOfStreamException]) {
            Write-Verbose "Identity recovery unavailable for Copilot session '$(Split-Path $SessionPath -Leaf)': $($_.Exception.GetBaseException().Message)"
            return $recorded
        }
        $message = "Invalid identity recovery evidence for Copilot session '$(Split-Path $SessionPath -Leaf)' in '$SessionPath': $($_.Exception.Message)"
        $recorded | Add-Member -NotePropertyName RecoveryError -NotePropertyValue ([System.Management.Automation.ErrorRecord]::new(
            [FormatException]::new($message), 'InvalidCopilotSessionIdentity',
            [System.Management.Automation.ErrorCategory]::InvalidData, $SessionPath))
        return $recorded
    }
}