src/core/State.ps1

#Requires -Version 5.1

function Get-State {
    <#
    .SYNOPSIS
    Loads course state from state.json, with automatic initialization if missing.

    .DESCRIPTION
    Reads state.json and returns a hashtable with version, last_poll timestamp, and tracked courses.
    If the file doesn't exist, creates the directory and returns an initialized state.
    On read error, returns a clean initial state rather than failing.

    .PARAMETER StateFile
    Path to state.json. Defaults to 'data/state.json' in the current directory.

    .OUTPUTS
    Hashtable with 'version' (int), 'last_poll' (ISO 8601 string), and 'last_notified' (array of course objects).

    .EXAMPLE
    $state = Get-State
    # Returns: @{ version = 1; last_poll = '2026-07-03T10:30:00Z'; last_notified = @() }

    .EXAMPLE
    $state = Get-State -StateFile 'custom/state.json'

    .NOTES
    File encoding is always UTF-8. Gracefully handles missing files and parse errors.
    #>

    [CmdletBinding()]
    param([ValidateNotNullOrEmpty()][string]$StateFile = 'data/state.json')
    $stateDir = Split-Path $StateFile
    if ($stateDir -and -not (Test-Path $stateDir)) { New-Item -ItemType Directory -Path $stateDir -Force | Out-Null }

    if (Test-Path $StateFile) {
        try {
            $stateJson = Get-Content $StateFile -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json
            $trackedCourses = if ($null -ne $stateJson.last_notified) { $stateJson.last_notified } else { @() }
            return @{ version = $stateJson.version; last_poll = $stateJson.last_poll; last_notified = $trackedCourses }
        }
        catch { return @{ version = 1; last_poll = [datetime]::UtcNow.ToString('o'); last_notified = @() } }
    }
    return @{ version = 1; last_poll = [datetime]::UtcNow.ToString('o'); last_notified = @() }
}

function Save-State {
    <#
    .SYNOPSIS
    Persists course state to state.json with formatted JSON output.

    .DESCRIPTION
    Writes the state hashtable to JSON file, including all course data and timestamps.
    Updates last_poll to current UTC time before persisting. Logs errors but does not throw.
    Creates directory if it doesn't exist.

    .PARAMETER State
    Hashtable with 'version', 'last_poll', and 'last_notified' (array of courses).

    .PARAMETER StateFile
    Path to state.json. Defaults to 'data/state.json' in the current directory.

    .EXAMPLE
    $state = @{
        version = 1
        last_poll = '2026-07-03T10:30:00Z'
        last_notified = @(
            @{ id = 'course|2026-07-15|10:00'; name = 'Basic 1'; date = '2026-07-15'; availability = 5 }
        )
    }
    Save-State -State $state

    .NOTES
    Does not throw on error; failures are logged via Write-Log with ERROR level.
    File encoding is always UTF-8.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param([ValidateNotNull()][hashtable]$State, [ValidateNotNullOrEmpty()][string]$StateFile = 'data/state.json')
    $stateDir = Split-Path $StateFile
    if ($stateDir -and -not (Test-Path $stateDir)) { New-Item -ItemType Directory -Path $stateDir -Force | Out-Null }
    try {
        $State.last_poll = [datetime]::UtcNow.ToString('o')
        $json = @("{")
        $json += ' "version": ' + ($State.version | ConvertTo-Json) + ','
        $json += ' "last_poll": ' + ($State.last_poll | ConvertTo-Json) + ','
        $json += ' "last_notified": ['

        if ($State.last_notified -and $State.last_notified.Count -gt 0) {
            foreach ($i in 0..($State.last_notified.Count - 1)) {
                $course = $State.last_notified[$i]
                $json += ' {'
                $json += ' "id": ' + ($course.id | ConvertTo-Json) + ','
                $json += ' "name": ' + ($course.name | ConvertTo-Json) + ','
                $json += ' "date": ' + ($course.date | ConvertTo-Json) + ','
                $json += ' "time": ' + ($course.time | ConvertTo-Json) + ','
                $json += ' "availability": ' + ($course.availability | ConvertTo-Json) + ','
                $json += ' "price": ' + ($course.price | ConvertTo-Json) + ','
                $json += ' "url": ' + ($course.url | ConvertTo-Json) + ','
                $json += ' "monitor_id": ' + ($course.monitor_id | ConvertTo-Json) + ','
                $json += ' "notified_at": ' + ($course.notified_at | ConvertTo-Json)
                if ($course.last_updated) { $json += ',' + "`n" + ' "last_updated": ' + ($course.last_updated | ConvertTo-Json) }
                if ($i -lt $State.last_notified.Count - 1) { $json += ' },' }
                else { $json += ' }' }
            }
        }
        $json += ' ]'
        $json += "}"

        if ($PSCmdlet.ShouldProcess($StateFile, "Write state")) {
            $json -join "`n" | Set-Content $StateFile -Encoding UTF8 -ErrorAction Stop
        }
    }
    catch { Write-Log -Level ERROR -Message "Failed to save state" -Context @{ file = $StateFile } -Exception $_ }
}

function Merge-CourseState {
    <#
    .SYNOPSIS
    Merges current courses with tracked state, detecting changes (NEW, REDUCED, SOLD_OUT).

    .DESCRIPTION
    Compares current website courses with previously tracked courses, detecting:
    - NEW: Courses not previously seen
    - REDUCED: Courses with decreased availability (>0)
    - SOLD_OUT: Courses with availability=0 or disappeared from website
    Returns alerts and merged state for persistence.

    .PARAMETER CurrentCourses
    Courses fetched from website (this run)

    .PARAMETER TrackedCourses
    Courses from last state.json (previous run)

    .OUTPUTS
    Hashtable with 'alerts' (new, reduced, sold_out) and 'updated_state' (merged courses for state.json)
    #>

    [CmdletBinding()]
    param([object[]]$CurrentCourses, [object[]]$TrackedCourses)

    $alerts = @{ new = @(); reduced = @(); sold_out = @() }
    $mergedState = @()

    # 1. Process all CURRENT courses (what website shows)
    foreach ($current in $CurrentCourses) {
        $courseId = if ($current.id) { $current.id } else { "$($current.name)|$($current.date)|$($current.time)" }
        $tracked = $TrackedCourses | Where-Object { $_.id -eq $courseId }

        if ($tracked) {
            # Course was previously tracked
            if ($current.availability -eq 0) {
                # Availability dropped to 0 = SOLD OUT
                $current | Add-Member -NotePropertyName 'alert_reason' -NotePropertyValue 'SOLD_OUT' -Force
                $alerts.sold_out += $current
                # Do NOT add to mergedState (delete it)
            }
            elseif ($current.availability -lt $tracked.availability) {
                # Availability decreased (but still > 0)
                $current | Add-Member -NotePropertyName 'alert_reason' -NotePropertyValue 'AVAILABILITY_REDUCED' -Force
                $alerts.reduced += $current
                # UPDATE in mergedState with new availability
                $mergedState += @{
                    id = $courseId
                    name = $current.name
                    date = $current.date
                    time = $current.time
                    availability = $current.availability
                    price = $current.price
                    url = $current.url
                    monitor_id = $current.monitor_id
                    notified_at = $tracked.notified_at
                    last_updated = [datetime]::UtcNow.ToString('o')
                }
            }
            else {
                # Availability unchanged (or increased - shouldn't happen per requirements)
                # UNCHANGED = do not alert, keep in state as-is
                $mergedState += $tracked
            }
        }
        else {
            # Course is NEW
            $current | Add-Member -NotePropertyName 'alert_reason' -NotePropertyValue 'NEW' -Force
            $alerts.new += $current
            # ADD to mergedState
            $mergedState += @{
                id = $courseId
                name = $current.name
                date = $current.date
                time = $current.time
                availability = $current.availability
                price = $current.price
                url = $current.url
                monitor_id = $current.monitor_id
                notified_at = [datetime]::UtcNow.ToString('o')
            }
        }
    }

    # 2. Process all TRACKED courses not in CURRENT (disappeared)
    foreach ($tracked in $TrackedCourses) {
        $courseId = $tracked.id
        $current = $CurrentCourses | Where-Object {
            $currentId = if ($_.id) { $_.id } else { "$($_.name)|$($_.date)|$($_.time)" }
            $currentId -eq $courseId
        }

        if (-not $current) {
            # Course was tracked but is NO LONGER on website
            $tracked | Add-Member -NotePropertyName 'alert_reason' -NotePropertyValue 'SOLD_OUT' -Force
            $tracked | Add-Member -NotePropertyName 'disappeared' -NotePropertyValue $true -Force
            $alerts.sold_out += $tracked
            # Do NOT add to mergedState (delete it)
        }
    }

    return @{
        alerts = $alerts
        updated_state = $mergedState
    }
}

function Update-StateWithCourse {
    <#
    .SYNOPSIS
    Merges current courses into state, returns both updated state and alerts.

    .DESCRIPTION
    Uses Merge-CourseState to intelligently update state.json, tracking only changes.
    Returns hashtable with 'state' (updated) and 'alerts' (for notifications).

    .PARAMETER State
    Current state hashtable from state.json

    .PARAMETER CurrentCourses
    Courses fetched this run

    .OUTPUTS
    Hashtable with 'state' and 'alerts'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param([ValidateNotNull()][hashtable]$State, [ValidateNotNull()][object[]]$CurrentCourses)

    if ($PSCmdlet.ShouldProcess("state.last_notified", "Update course state")) {
        $trackedCourses = if ($null -ne $State.last_notified) { $State.last_notified } else { @() }
        $mergeResult = Merge-CourseState -CurrentCourses $CurrentCourses -TrackedCourses $trackedCourses
        $State.last_notified = $mergeResult.updated_state
    }
    else {
        $mergeResult = @{ alerts = @{ new = @(); reduced = @(); sold_out = @() }; updated_state = $State.last_notified }
    }

    return @{
        state = $State
        alerts = $mergeResult.alerts
    }
}

function Get-NewCourse {
    <#
    .SYNOPSIS
    DEPRECATED: Use Merge-CourseState instead.

    .DESCRIPTION
    Legacy function kept for backwards compatibility. New code should use Merge-CourseState
    which properly handles SOLD_OUT courses and state merging.
    #>

    param([object[]]$CurrentCourses, [object[]]$PreviousCourses)

    Write-Log -Level WARN -Message "Get-NewCourses is deprecated, use Merge-CourseState instead"

    $trackedCourses = if ($null -ne $PreviousCourses) { $PreviousCourses } else { @() }
    $mergeResult = Merge-CourseState -CurrentCourses $CurrentCourses -TrackedCourses $trackedCourses
    return $mergeResult.alerts.new + $mergeResult.alerts.reduced
}