scripts/internal/host-history.ps1

# Host-history persistence (F-043 / Proposal 104)
#
# Helpers for reading/writing .specrew/host-history.json (registry-driven).
# Per spec FR-001 through FR-004 + FR-012.
#
# Spec note: FR-001 originally mandated host-history.yml. Implementation uses
# .json (built-in ConvertFrom-Json / ConvertTo-Json) to avoid the powershell-yaml
# external dependency. JSON also matches the existing pattern (.specrew/start-context.json,
# .specrew/feature-status.json). Schema fields are spec-conformant; only the
# serialization format differs.
#
# Architecture: host enum NOT hardcoded — initial host entries come from
# hosts/_registry.ps1 Get-RegisteredHostKinds so adding hosts/<new-kind>/
# automatically extends the history schema.

Set-StrictMode -Version Latest

$script:RegistryPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'hosts\_registry.ps1'
if (-not (Test-Path -LiteralPath $script:RegistryPath -PathType Leaf)) {
    $script:RegistryPath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'hosts\_registry.ps1'
}
if (-not (Test-Path -LiteralPath $script:RegistryPath -PathType Leaf)) {
    throw "Host registry not found. Searched: $script:RegistryPath"
}
. $script:RegistryPath

function Get-SpecrewHostHistoryPath {
    param([Parameter(Mandatory = $true)][string]$ProjectPath)
    return (Join-Path $ProjectPath '.specrew\host-history.json')
}

function Get-SpecrewHostHistory {
    <#
    .SYNOPSIS
    Read .specrew/host-history.json. Returns $null if missing or corrupted.
    Tolerates corruption per Proposal 059 read-tolerance pattern.
    .OUTPUTS
    pscustomobject or $null
    #>

    param([Parameter(Mandatory = $true)][string]$ProjectPath)

    $path = Get-SpecrewHostHistoryPath -ProjectPath $ProjectPath
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
        return $null
    }

    try {
        $raw = Get-Content -LiteralPath $path -Raw -Encoding UTF8
        if ([string]::IsNullOrWhiteSpace($raw)) {
            Write-Warning "host-history.json is empty; treating as missing"
            return $null
        }
        $content = $raw | ConvertFrom-Json
        if (-not (Test-SpecrewHostHistorySchema -Content $content)) {
            Write-Warning "host-history.json schema invalid; regenerating from probe"
            return $null
        }
        return $content
    }
    catch {
        Write-Warning "host-history.json corrupted: $($_.Exception.Message). Regenerating from probe."
        return $null
    }
}

function Test-SpecrewHostHistorySchema {
    <#
    .SYNOPSIS
    Validate a parsed host-history.json against schema v1.
    .OUTPUTS
    bool
    #>

    param([Parameter(Mandatory = $true)][object]$Content)

    if ($null -eq $Content) { return $false }

    # Accept either flat root or { host_history: {...} } nesting (spec syntax)
    $root = if ($Content.PSObject.Properties.Name -contains 'host_history') { $Content.host_history } else { $Content }

    if ($null -eq $root) { return $false }

    foreach ($required in 'schema_version', 'hosts') {
        if ($null -eq $root.PSObject.Properties[$required]) {
            Write-Warning "host_history missing required field: $required"
            return $false
        }
    }

    if ($root.schema_version -ne 1) {
        Write-Warning "host_history schema_version is $($root.schema_version); expected 1"
        return $false
    }

    return $true
}

function New-SpecrewHostHistory {
    <#
    .SYNOPSIS
    Construct a fresh host-history hashtable.
    Host entries are initialized from the registry — adding hosts/<new-kind>/
    extends the schema automatically with no edits to this function.
    .OUTPUTS
    hashtable
    #>

    param()

    $hosts = [ordered]@{}
    foreach ($kind in Get-RegisteredHostKinds) {
        $hosts[$kind] = [ordered]@{
            first_used_at          = $null
            last_used_at           = $null
            crew_runtime_installed = $false
            crew_runtime_path      = $null
        }
    }

    return [ordered]@{
        host_history = [ordered]@{
            schema_version     = 1
            last_selected_host = $null
            hosts              = $hosts
        }
    }
}

function Update-SpecrewHostHistory {
    <#
    .SYNOPSIS
    Update host-history after a host selection. Per FR-004.
    #>

    param(
        [Parameter(Mandatory = $true)][string]$ProjectPath,
        [Parameter(Mandatory = $true)][string]$SelectedHost,
        [bool]$CrewRuntimeInstalled = $false,
        [string]$CrewRuntimePath
    )

    $selectedHostLower = $SelectedHost.ToLowerInvariant()
    $history = Get-SpecrewHostHistory -ProjectPath $ProjectPath
    if ($null -eq $history) {
        $history = New-SpecrewHostHistory
    }
    else {
        # Convert ConvertFrom-Json output (pscustomobject) to an editable hashtable form
        $history = ConvertTo-EditableHashtable -InputObject $history
    }

    $now = [DateTime]::UtcNow.ToString('o')
    $hostsBlock = $history['host_history']['hosts']

    if (-not $hostsBlock.Contains($selectedHostLower)) {
        $hostsBlock[$selectedHostLower] = [ordered]@{
            first_used_at          = $null
            last_used_at           = $null
            crew_runtime_installed = $false
            crew_runtime_path      = $null
        }
    }

    if ([string]::IsNullOrWhiteSpace([string]$hostsBlock[$selectedHostLower]['first_used_at'])) {
        $hostsBlock[$selectedHostLower]['first_used_at'] = $now
    }
    $hostsBlock[$selectedHostLower]['last_used_at'] = $now
    $hostsBlock[$selectedHostLower]['crew_runtime_installed'] = $CrewRuntimeInstalled
    $hostsBlock[$selectedHostLower]['crew_runtime_path'] = $CrewRuntimePath

    $history['host_history']['last_selected_host'] = $selectedHostLower

    Write-SpecrewHostHistory -ProjectPath $ProjectPath -History $history
    return $history
}

function ConvertTo-EditableHashtable {
    # Recursively convert PSCustomObject (from ConvertFrom-Json) to ordered hashtable
    param([Parameter(ValueFromPipeline = $true)]$InputObject)

    if ($null -eq $InputObject) { return $null }
    if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject }
    if ($InputObject -is [PSCustomObject]) {
        $result = [ordered]@{}
        foreach ($prop in $InputObject.PSObject.Properties) {
            $result[$prop.Name] = ConvertTo-EditableHashtable -InputObject $prop.Value
        }
        return $result
    }
    if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) {
        return @(foreach ($item in $InputObject) { ConvertTo-EditableHashtable -InputObject $item })
    }
    return $InputObject
}

function Write-SpecrewHostHistory {
    <#
    .SYNOPSIS
    Serialize and write the host-history.json (UTF-8, no BOM, atomic via temp+rename).
    #>

    param(
        [Parameter(Mandatory = $true)][string]$ProjectPath,
        [Parameter(Mandatory = $true)][object]$History
    )

    $path = Get-SpecrewHostHistoryPath -ProjectPath $ProjectPath
    $dir = Split-Path -Parent $path
    if (-not (Test-Path -LiteralPath $dir)) {
        $null = New-Item -ItemType Directory -Path $dir -Force
    }

    $json = ConvertTo-Json -InputObject $History -Depth 10
    $tempPath = "$path.tmp"
    try {
        [System.IO.File]::WriteAllText($tempPath, $json, [System.Text.UTF8Encoding]::new($false))
        if (Test-Path -LiteralPath $path -PathType Leaf) {
            Remove-Item -LiteralPath $path -Force
        }
        Move-Item -LiteralPath $tempPath -Destination $path -Force
    }
    catch {
        if (Test-Path -LiteralPath $tempPath -PathType Leaf) {
            Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
        }
        throw
    }
}

function Resolve-SpecrewHostFromHistory {
    <#
    .SYNOPSIS
    Determine the host to use based on FR-002 priority order:
      1. --host flag (if provided)
      2. host-history.json last_selected_host (if present)
      3. (null — caller should fall through to first-run probe or exit)
 
    .OUTPUTS
    pscustomobject @{ Host = <string or $null>; Source = 'flag' | 'last-selected' | 'unresolved' }
    #>

    param(
        [Parameter(Mandatory = $true)][string]$ProjectPath,
        [string]$ExplicitHost
    )

    if (-not [string]::IsNullOrWhiteSpace($ExplicitHost)) {
        return [pscustomobject]@{ Host = $ExplicitHost.ToLowerInvariant(); Source = 'flag' }
    }

    $history = Get-SpecrewHostHistory -ProjectPath $ProjectPath
    if ($null -ne $history) {
        $root = if ($history.PSObject.Properties.Name -contains 'host_history') { $history.host_history } else { $history }
        $last = $root.last_selected_host
        if (-not [string]::IsNullOrWhiteSpace([string]$last)) {
            return [pscustomobject]@{ Host = [string]$last; Source = 'last-selected' }
        }
    }

    return [pscustomobject]@{ Host = $null; Source = 'unresolved' }
}

function Test-SpecrewHostBinaryAvailable {
    <#
    .SYNOPSIS
    Probes the host's primary Binary + every entry in BinaryAliases. Returns the
    actual command-name that resolved (for diagnostics), or $null if none on PATH.
    .OUTPUTS
    string (resolved binary name) or $null
    #>

    param([Parameter(Mandatory = $true)][string]$Kind)

    $manifest = Get-HostManifest -Kind $Kind
    $candidates = @([string]$manifest.Binary)
    if ($manifest.ContainsKey('BinaryAliases') -and $null -ne $manifest.BinaryAliases) {
        foreach ($alias in @($manifest.BinaryAliases)) {
            if (-not [string]::IsNullOrWhiteSpace([string]$alias)) {
                $candidates += [string]$alias
            }
        }
    }
    foreach ($binary in $candidates) {
        if ($null -ne (Get-Command $binary -ErrorAction SilentlyContinue)) {
            return $binary
        }
    }
    return $null
}

function Invoke-SpecrewFirstRunHostProbe {
    <#
    .SYNOPSIS
    Per FR-003: probe PATH for supported hosts (Binary + BinaryAliases), present
    a numbered menu of installed hosts plus a "not installed" group, auto-select
    if exactly 1 is installed, exit with guidance if 0.
 
    .DESCRIPTION
    Returns a pscustomobject @{ Host = <string-or-null>; Source = 'auto-single-available' | 'first-run-prompt' | 'no-hosts-available'; Available[] }.
    When Source = 'no-hosts-available', caller should print install guidance + exit non-zero.
    When stdin is non-TTY and multiple hosts available, returns @{ Host = $null; Source = 'non-interactive-no-default' } per FR-013.
 
    Interactive menu (when multiple installed hosts):
      1. copilot — GitHub Copilot CLI
      2. claude — Claude Code CLI
      3. codex — OpenAI Codex CLI
      Other supported (not installed on this PATH):
       - antigravity — Google Antigravity CLI (install: https://antigravity.google/)
      Select 1-3 (number) or kind name [default 1]:
 
    .PARAMETER NonInteractive
    Force non-interactive behavior (for tests). Auto-detected via [Console]::IsInputRedirected when not specified.
    #>

    param(
        [bool]$NonInteractive = [Console]::IsInputRedirected
    )

    # Get supported (non-deferred) hosts from registry; probe each for Binary + BinaryAliases
    $supportedKinds = @(Get-SpecrewHostsByStatus -Status supported)
    $availableKinds = @()
    $unavailableKinds = @()
    foreach ($kind in $supportedKinds) {
        if ($null -ne (Test-SpecrewHostBinaryAvailable -Kind $kind)) {
            $availableKinds += $kind
        }
        else {
            $unavailableKinds += $kind
        }
    }

    if ($availableKinds.Count -eq 0) {
        return [pscustomobject]@{
            Host      = $null
            Source    = 'no-hosts-available'
            Available = @()
        }
    }

    if ($availableKinds.Count -eq 1) {
        return [pscustomobject]@{
            Host      = $availableKinds[0]
            Source    = 'auto-single-available'
            Available = $availableKinds
        }
    }

    # Multiple available — interactive menu or non-TTY exit per FR-013
    if ($NonInteractive) {
        return [pscustomobject]@{
            Host      = $null
            Source    = 'non-interactive-no-default'
            Available = $availableKinds
        }
    }

    # Interactive numbered menu
    Write-Host ''
    Write-Host 'Select host for this Specrew session:' -ForegroundColor Cyan
    Write-Host ''
    Write-Host 'Installed on this machine:' -ForegroundColor Green
    $menuIndex = 0
    foreach ($kind in $availableKinds) {
        $menuIndex++
        $manifest = Get-HostManifest -Kind $kind
        Write-Host (' {0}. {1,-12} — {2}' -f $menuIndex, $kind, [string]$manifest.DisplayName)
    }
    if ($unavailableKinds.Count -gt 0) {
        Write-Host ''
        Write-Host 'Other supported hosts (not installed on this PATH):' -ForegroundColor DarkGray
        foreach ($kind in $unavailableKinds) {
            $manifest = Get-HostManifest -Kind $kind
            $installUrl = if ($manifest.ContainsKey('InstallUrl')) { [string]$manifest.InstallUrl } else { '' }
            $urlHint = if (-not [string]::IsNullOrWhiteSpace($installUrl)) { " (install: $installUrl)" } else { '' }
            Write-Host (' - {0,-12} — {1}{2}' -f $kind, [string]$manifest.DisplayName, $urlHint) -ForegroundColor DarkGray
        }
    }
    Write-Host ''
    while ($true) {
        $rawInput = (Read-Host ("Select 1-{0} (number) or kind name [default 1]" -f $availableKinds.Count)).Trim()
        if ([string]::IsNullOrWhiteSpace($rawInput)) {
            $rawInput = '1'
        }
        # Numeric selection
        $asInt = 0
        if ([int]::TryParse($rawInput, [ref]$asInt)) {
            if ($asInt -ge 1 -and $asInt -le $availableKinds.Count) {
                return [pscustomobject]@{
                    Host      = $availableKinds[$asInt - 1]
                    Source    = 'first-run-prompt'
                    Available = $availableKinds
                }
            }
            Write-Host ("Invalid number '{0}'. Pick 1-{1}." -f $rawInput, $availableKinds.Count) -ForegroundColor Red
            continue
        }
        # Kind-name selection (backwards-compat)
        $choiceLower = $rawInput.ToLowerInvariant()
        if ($availableKinds -contains $choiceLower) {
            return [pscustomobject]@{
                Host      = $choiceLower
                Source    = 'first-run-prompt'
                Available = $availableKinds
            }
        }
        if ($unavailableKinds -contains $choiceLower) {
            $manifest = Get-HostManifest -Kind $choiceLower
            $installUrl = if ($manifest.ContainsKey('InstallUrl')) { [string]$manifest.InstallUrl } else { 'see host docs' }
            Write-Host ("Host '{0}' is supported but not installed on this PATH. Install: {1}" -f $choiceLower, $installUrl) -ForegroundColor Yellow
            continue
        }
        Write-Host ("Invalid choice '{0}'. Pick 1-{1} or one of: {2}" -f $rawInput, $availableKinds.Count, ($availableKinds -join ', ')) -ForegroundColor Red
    }
}