[CmdletBinding()]
param(
    [string]$OutputPath = "..\\Tornado3_2026Election\\Assets\\Data\\pre_election_history.json",
    [string]$ServiceKey = $env:NEC_SERVICE_KEY
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

if ([string]::IsNullOrWhiteSpace($ServiceKey))
{
    throw "NEC_SERVICE_KEY environment variable or -ServiceKey is required."
}

function Decode-Text {
    param([string]$Value)

    return [uri]::UnescapeDataString($Value)
}

function Get-FirstValue {
    param(
        [System.Collections.IDictionary]$Table,
        [string[]]$Keys
    )

    foreach ($key in $Keys)
    {
        if ($Table.Contains($key) -and $null -ne $Table[$key])
        {
            $value = [string]$Table[$key]
            if (-not [string]::IsNullOrWhiteSpace($value))
            {
                return $value
            }
        }
    }

    return [string]::Empty
}

function Get-XmlItems {
    param([xml]$Xml)

    if ($null -eq $Xml)
    {
        return @()
    }

    $itemNodes = $Xml.SelectNodes("//item")
    if ($null -eq $itemNodes)
    {
        return @()
    }

    return @($itemNodes)
}

function Invoke-NecXml {
    param(
        [string]$ServicePath,
        [hashtable]$Parameters
    )

    $pairs = [System.Collections.Generic.List[string]]::new()
    $pairs.Add("serviceKey=$([uri]::EscapeDataString($ServiceKey))")
    foreach ($key in ($Parameters.Keys | Sort-Object))
    {
        $rawValue = $Parameters[$key]
        if ($null -eq $rawValue)
        {
            continue
        }

        $stringValue = [string]$rawValue
        if ([string]::IsNullOrWhiteSpace($stringValue))
        {
            continue
        }

        $pairs.Add("$([uri]::EscapeDataString($key))=$([uri]::EscapeDataString($stringValue))")
    }

    $uri = "https://apis.data.go.kr/9760000/${ServicePath}?$($pairs -join '&')"
    for ($attempt = 1; $attempt -le 3; $attempt++)
    {
        try
        {
            [xml]$xml = (Invoke-WebRequest -UseBasicParsing -Uri $uri).Content
            $resultCode = [string]$xml.response.header.resultCode
            if (-not [string]::IsNullOrWhiteSpace($resultCode) -and $resultCode -notin @("00", "INFO-00", "INFO-03"))
            {
                $resultMessage = [string]$xml.response.header.resultMsg
                throw "API failure [$resultCode] $resultMessage"
            }

            return $xml
        }
        catch
        {
            if ($attempt -ge 3)
            {
                throw
            }

            Start-Sleep -Milliseconds (250 * $attempt)
        }
    }

    throw "Failed to call $ServicePath"
}

function Get-LocalElectionCycles {
    param(
        [System.Collections.IEnumerable]$CodeItems,
        [string]$SgTypeCode
    )

    $localElectionPattern = '^' +
        [regex]::Escape($(Decode-Text "%EC%A0%9C")) +
        '(?<order>\d+)' +
        [regex]::Escape($(Decode-Text "%ED%9A%8C%20%EC%A0%84%EA%B5%AD%EB%8F%99%EC%8B%9C%EC%A7%80%EB%B0%A9%EC%84%A0%EA%B1%B0")) +
        '$'

    $cycleMap = @{}
    foreach ($item in $CodeItems)
    {
        $sgName = [string]$item.sgName
        if ($sgName -notmatch $localElectionPattern)
        {
            continue
        }

        if ([string]$item.sgTypecode -ne $SgTypeCode)
        {
            continue
        }

        $sgId = [string]$item.sgId
        if ([string]::IsNullOrWhiteSpace($sgId) -or [int]$sgId -gt 20220601)
        {
            continue
        }

        $order = [int]$Matches["order"]
        $cycleMap[$order] = [pscustomobject]@{
            Order = $order
            Year = [int]$sgId.Substring(0, 4)
            SgId = $sgId
            SgTypeCode = $SgTypeCode
        }
    }

    return @($cycleMap.Values | Sort-Object Order)
}

function New-SourceReference {
    param(
        [string]$Title,
        [string]$Url
    )

    return [pscustomobject][ordered]@{
        Title = $Title
        Url = $Url
    }
}

function Get-RegionApiNames {
    param([object]$Region)

    $names = [System.Collections.Generic.List[string]]::new()
    $primaryName = Decode-Text $Region.DisplayName
    if (-not [string]::IsNullOrWhiteSpace($primaryName))
    {
        $names.Add($primaryName)
    }

    if ($Region.Contains("ApiNames"))
    {
        foreach ($encodedName in $Region.ApiNames)
        {
            $decodedName = Decode-Text $encodedName
            if (-not [string]::IsNullOrWhiteSpace($decodedName) -and -not $names.Contains($decodedName))
            {
                $names.Add($decodedName)
            }
        }
    }

    return @($names)
}

function New-HistoryRecord {
    param(
        [string]$ElectionType,
        [string]$Key,
        [string]$RegionKey,
        [string]$RegionName,
        [string]$DistrictName,
        [string]$DisplayName
    )

    return [ordered]@{
        ElectionType = $ElectionType
        Key = $Key
        RegionKey = $RegionKey
        RegionName = $RegionName
        DistrictName = $DistrictName
        DisplayName = $DisplayName
        TurnoutHistory = [System.Collections.Generic.List[object]]::new()
        WinnerHistory = [System.Collections.Generic.List[object]]::new()
    }
}

function Convert-RecordForJson {
    param([System.Collections.IDictionary]$Record)

    return [ordered]@{
        ElectionType = $Record.ElectionType
        Key = $Record.Key
        RegionKey = $Record.RegionKey
        RegionName = $Record.RegionName
        DistrictName = $Record.DistrictName
        DisplayName = $Record.DisplayName
        TurnoutHistory = @($Record.TurnoutHistory | Sort-Object ElectionOrder)
        WinnerHistory = @($Record.WinnerHistory | Sort-Object ElectionOrder)
    }
}

function Add-HistoryEntry {
    param(
        [System.Collections.Generic.List[object]]$Target,
        [object]$Entry
    )

    if ($null -eq $Entry)
    {
        return
    }

    $existing = $Target | Where-Object { $_.ElectionOrder -eq $Entry.ElectionOrder } | Select-Object -First 1
    if ($null -eq $existing)
    {
        $Target.Add($Entry)
    }
}

function Normalize-CompactText {
    param([string]$Value)

    if ([string]::IsNullOrWhiteSpace($Value))
    {
        return [string]::Empty
    }

    return ($Value -replace '\s+', [string]::Empty).Trim()
}

function Strip-BasicDistrictDisambiguation {
    param([string]$Value)

    if ([string]::IsNullOrWhiteSpace($Value))
    {
        return [string]::Empty
    }

    $normalized = $Value.Trim()
    $regionLabels = @(
        "%EC%84%9C%EC%9A%B8",
        "%EB%B6%80%EC%82%B0",
        "%EB%8C%80%EA%B5%AC",
        "%EC%9D%B8%EC%B2%9C",
        "%EA%B4%91%EC%A3%BC",
        "%EB%8C%80%EC%A0%84",
        "%EC%9A%B8%EC%82%B0",
        "%EC%84%B8%EC%A2%85",
        "%EA%B2%BD%EA%B8%B0",
        "%EA%B0%95%EC%9B%90",
        "%EC%B6%A9%EB%B6%81",
        "%EC%B6%A9%EB%82%A8",
        "%EC%A0%84%EB%B6%81",
        "%EC%A0%84%EB%82%A8",
        "%EA%B2%BD%EB%B6%81",
        "%EA%B2%BD%EB%82%A8",
        "%EC%A0%9C%EC%A3%BC"
    )

    foreach ($encodedLabel in $regionLabels)
    {
        $label = Decode-Text $encodedLabel
        $normalized = $normalized.Replace("($label)", [string]::Empty)
    }

    return $normalized.Replace("()", [string]::Empty).Trim()
}

function Normalize-BasicDistrictToken {
    param([string]$Value)

    $normalized = Normalize-CompactText -Value (Strip-BasicDistrictDisambiguation -Value $Value)
    if ([string]::IsNullOrWhiteSpace($normalized))
    {
        return [string]::Empty
    }

    $normalized = $normalized.Replace($(Decode-Text "%EA%B5%AC%EC%B2%AD%EC%9E%A5"), $(Decode-Text "%EA%B5%AC"))
    $normalized = $normalized.Replace($(Decode-Text "%EA%B5%B0%EC%88%98"), $(Decode-Text "%EA%B5%B0"))
    $normalized = $normalized.Replace($(Decode-Text "%EC%8B%9C%EC%9E%A5"), $(Decode-Text "%EC%8B%9C"))
    $normalized = $normalized.Replace($(Decode-Text "%EA%B5%90%EC%9C%A1%EA%B0%90"), [string]::Empty)
    $normalized = $normalized.Replace("()", [string]::Empty)
    return $normalized.Trim()
}

function Normalize-BasicDistrictDisplayName {
    param(
        [string]$DistrictName,
        [string]$DisplayName,
        [string]$RegionName
    )

    $normalized = if ([string]::IsNullOrWhiteSpace($DistrictName)) { $DisplayName } else { $DistrictName }
    if ([string]::IsNullOrWhiteSpace($normalized))
    {
        return [string]::Empty
    }

    $normalized = $normalized.Trim()
    if (-not [string]::IsNullOrWhiteSpace($RegionName) -and $normalized.StartsWith($RegionName.Trim(), [System.StringComparison]::Ordinal))
    {
        $normalized = $normalized.Substring($RegionName.Trim().Length).Trim()
    }

    return Strip-BasicDistrictDisambiguation -Value $normalized
}

function New-OfficialWinnerEntry {
    param(
        [pscustomobject]$Cycle,
        [object]$Item,
        [string]$SourceUrl
    )

    if ($null -eq $Item)
    {
        return $null
    }

    $name = ([string]$Item.name).Trim()
    if ([string]::IsNullOrWhiteSpace($name))
    {
        return $null
    }

    return [ordered]@{
        ElectionOrder = $Cycle.Order
        Year = $Cycle.Year
        Name = $name
        Party = ([string]$Item.jdName).Trim()
        Note = [string]::Empty
        SourceUrl = $SourceUrl
    }
}

function New-OfficialTurnoutEntry {
    param(
        [pscustomobject]$Cycle,
        [int]$Electors,
        [int]$Votes,
        [string]$SourceUrl
    )

    if ($Electors -le 0 -or $Votes -le 0)
    {
        return $null
    }

    return [ordered]@{
        ElectionOrder = $Cycle.Order
        Year = $Cycle.Year
        Electors = $Electors
        Votes = $Votes
        TurnoutRate = [Math]::Round(($Votes * 100.0) / $Electors, 1, [MidpointRounding]::AwayFromZero)
        SourceUrl = $SourceUrl
    }
}

function Resolve-BasicTurnoutSnapshot {
    param(
        [string]$DistrictName,
        [object[]]$TurnoutItems
    )

    $normalizedTarget = Normalize-CompactText -Value $DistrictName
    if ([string]::IsNullOrWhiteSpace($normalizedTarget))
    {
        return $null
    }

    $exactMatches = @(
        $TurnoutItems |
            Where-Object { (Normalize-CompactText -Value ([string]$_.wiwName)) -eq $normalizedTarget }
    )

    $matchedItems = @(
        if ($exactMatches.Count -gt 0)
        {
            $exactMatches
        }
        else
        {
            @(
                $TurnoutItems |
                    Where-Object {
                        $normalizedWiwName = Normalize-CompactText -Value ([string]$_.wiwName)
                        -not [string]::IsNullOrWhiteSpace($normalizedWiwName) -and
                            $normalizedWiwName.StartsWith($normalizedTarget, [System.StringComparison]::Ordinal)
                    }
            )
        }
    )

    if ($matchedItems.Count -eq 0)
    {
        return $null
    }

    $electors = 0
    $votes = 0
    foreach ($matchedItem in $matchedItems)
    {
        $electors += Parse-PositiveInt -Value ([string]$matchedItem.totSunsu)
        $votes += Parse-PositiveInt -Value ([string]$matchedItem.totTusu)
    }

    if ($electors -le 0 -or $votes -le 0)
    {
        return $null
    }

    return [pscustomobject]@{
        Electors = $electors
        Votes = $votes
    }
}

$yearByOrder = @{
    1 = 1995
    2 = 1998
    3 = 2002
    4 = 2006
    5 = 2010
    6 = 2014
    7 = 2018
    8 = 2022
}

$governorType = Decode-Text "%EA%B4%91%EC%97%AD%EB%8B%A8%EC%B2%B4%EC%9E%A5"
$educationType = Decode-Text "%EA%B5%90%EC%9C%A1%EA%B0%90"
$basicType = Decode-Text "%EA%B8%B0%EC%B4%88%EB%8B%A8%EC%B2%B4%EC%9E%A5"
$redirectKeyword = Decode-Text "%EB%84%98%EA%B2%A8%EC%A3%BC%EA%B8%B0"
$headingPrefix = Decode-Text "%EC%A0%9C"
$headingSuffix = Decode-Text "%ED%9A%8C%20%EC%A0%84%EA%B5%AD%EB%8F%99%EC%8B%9C%EC%A7%80%EB%B0%A9%EC%84%A0%EA%B1%B0"
$winnerTemplatePrefix = Decode-Text "%EB%8B%B9%EC%84%A0%20%EC%84%A0%EA%B1%B0%EA%B2%B0%EA%B3%BC"
$turnoutStartTemplatePrefix = Decode-Text "%EC%84%A0%EA%B1%B0%EA%B2%B0%EA%B3%BC%20%EC%8B%9C%EC%9E%91"
$turnoutTotalTemplatePrefix = Decode-Text "%EC%84%A0%EA%B1%B0%EA%B2%B0%EA%B3%BC%20%ED%95%A9%EA%B3%84"
$candidateKey = Decode-Text "%ED%9B%84%EB%B3%B4"
$candidateNameKey = Decode-Text "%ED%9B%84%EB%B3%B4%EB%AA%85"
$partyKey = Decode-Text "%EC%A0%95%EB%8B%B9"
$partyNameKey = Decode-Text "%EC%A0%95%EB%8B%B9%EB%AA%85"
$noteKey = Decode-Text "%EB%B9%84%EA%B3%A0"
$electorsKey = Decode-Text "%EC%9C%A0%EA%B6%8C%EC%9E%90"
$totalElectorsKey = Decode-Text "%EC%B4%9D%EC%84%A0%EA%B1%B0%EC%9D%B8%EC%88%98"
$totalVotesKey = Decode-Text "%EC%B4%9D%ED%88%AC%ED%91%9C%EC%9E%90%EC%88%98"
$sumKey = Decode-Text "%ED%95%A9%EA%B3%84"
$totalLabel = Decode-Text "%ED%95%A9%EA%B3%84"
$electionHeadingPattern = [regex]::Escape($headingPrefix) + "(?<order>\d+)" + [regex]::Escape($headingSuffix)

$codeInfoSourceUrl = "https://www.data.go.kr/data/15000897/openapi.do"
$winnerSourceUrl = "https://www.data.go.kr/data/15000864/openapi.do"
$turnoutSourceUrl = "https://www.data.go.kr/data/15000900/openapi.do"

$officialSources = @(
    (New-SourceReference -Title "NEC Code Info" -Url $codeInfoSourceUrl),
    (New-SourceReference -Title "NEC Winner Info" -Url $winnerSourceUrl),
    (New-SourceReference -Title "NEC Vote Turnout Info" -Url $turnoutSourceUrl)
)

$regions = @(
    [ordered]@{ Key = "%EC%84%9C%EC%9A%B8"; DisplayName = "%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C"; Titles = @("%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EB%B6%80%EC%82%B0"; DisplayName = "%EB%B6%80%EC%82%B0%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EB%B6%80%EC%82%B0%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EB%8C%80%EA%B5%AC"; DisplayName = "%EB%8C%80%EA%B5%AC%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EB%8C%80%EA%B5%AC%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EC%9D%B8%EC%B2%9C"; DisplayName = "%EC%9D%B8%EC%B2%9C%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EC%9D%B8%EC%B2%9C%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EA%B4%91%EC%A3%BC"; DisplayName = "%EA%B4%91%EC%A3%BC%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EA%B4%91%EC%A3%BC%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EB%8C%80%EC%A0%84"; DisplayName = "%EB%8C%80%EC%A0%84%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EB%8C%80%EC%A0%84%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EC%9A%B8%EC%82%B0"; DisplayName = "%EC%9A%B8%EC%82%B0%EA%B4%91%EC%97%AD%EC%8B%9C"; Titles = @("%EC%9A%B8%EC%82%B0%EA%B4%91%EC%97%AD%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EC%84%B8%EC%A2%85"; DisplayName = "%EC%84%B8%EC%A2%85%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EC%8B%9C"; Titles = @("%EC%84%B8%EC%A2%85%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EC%8B%9C%EC%9E%A5%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EA%B2%BD%EA%B8%B0"; DisplayName = "%EA%B2%BD%EA%B8%B0%EB%8F%84"; Titles = @("%EA%B2%BD%EA%B8%B0%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EA%B0%95%EC%9B%90"; DisplayName = "%EA%B0%95%EC%9B%90%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84"; Titles = @("%EA%B0%95%EC%9B%90%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0", "%EA%B0%95%EC%9B%90%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0"); ApiNames = @("%EA%B0%95%EC%9B%90%EB%8F%84") },
    [ordered]@{ Key = "%EC%B6%A9%EB%B6%81"; DisplayName = "%EC%B6%A9%EC%B2%AD%EB%B6%81%EB%8F%84"; Titles = @("%EC%B6%A9%EC%B2%AD%EB%B6%81%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EC%B6%A9%EB%82%A8"; DisplayName = "%EC%B6%A9%EC%B2%AD%EB%82%A8%EB%8F%84"; Titles = @("%EC%B6%A9%EC%B2%AD%EB%82%A8%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EC%A0%84%EB%B6%81"; DisplayName = "%EC%A0%84%EB%B6%81%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84"; Titles = @("%EC%A0%84%EB%B6%81%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0", "%EC%A0%84%EB%9D%BC%EB%B6%81%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0"); ApiNames = @("%EC%A0%84%EB%9D%BC%EB%B6%81%EB%8F%84") },
    [ordered]@{ Key = "%EC%A0%84%EB%82%A8"; DisplayName = "%EC%A0%84%EB%9D%BC%EB%82%A8%EB%8F%84"; Titles = @("%EC%A0%84%EB%9D%BC%EB%82%A8%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EA%B2%BD%EB%B6%81"; DisplayName = "%EA%B2%BD%EC%83%81%EB%B6%81%EB%8F%84"; Titles = @("%EA%B2%BD%EC%83%81%EB%B6%81%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EA%B2%BD%EB%82%A8"; DisplayName = "%EA%B2%BD%EC%83%81%EB%82%A8%EB%8F%84"; Titles = @("%EA%B2%BD%EC%83%81%EB%82%A8%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") },
    [ordered]@{ Key = "%EC%A0%9C%EC%A3%BC"; DisplayName = "%EC%A0%9C%EC%A3%BC%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84"; Titles = @("%EC%A0%9C%EC%A3%BC%ED%8A%B9%EB%B3%84%EC%9E%90%EC%B9%98%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0", "%EC%A0%9C%EC%A3%BC%EB%8F%84%EC%A7%80%EC%82%AC%20%EC%84%A0%EA%B1%B0") }
)

function Get-WikiPageUrl {
    param([string]$Title)

    return "https://ko.wikipedia.org/wiki/$([uri]::EscapeDataString($Title.Replace(' ', '_')))"
}

function Get-WikiRawUrl {
    param([string]$Title)

    return "https://ko.wikipedia.org/w/index.php?title=$([uri]::EscapeDataString($Title.Replace(' ', '_')))&action=raw"
}

function Get-WikiPage {
    param([object]$Region)

    $attempted = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    $queue = [System.Collections.Generic.Queue[string]]::new()
    foreach ($encodedTitle in $Region.Titles)
    {
        $title = Decode-Text $encodedTitle
        if ($attempted.Add($title))
        {
            $queue.Enqueue($title)
        }
    }

    while ($queue.Count -gt 0)
    {
        $title = $queue.Dequeue()
        $rawUrl = Get-WikiRawUrl -Title $title
        try
        {
            $response = Invoke-WebRequest -UseBasicParsing -Uri $rawUrl
        }
        catch
        {
            continue
        }

        $content = [string]$response.Content
        if ($null -eq $content)
        {
            $content = [string]::Empty
        }

        $content = $content -replace "`r", [string]::Empty
        if ([string]::IsNullOrWhiteSpace($content))
        {
            continue
        }

        $redirectPattern = '^\s*#(?:' + [regex]::Escape($redirectKeyword) + '|redirect)\s*\[\[(?<target>[^\]]+)\]\]'
        if ($content -match $redirectPattern)
        {
            $redirectTarget = ($Matches["target"] -replace '\|.*$', [string]::Empty).Trim()
            if (-not [string]::IsNullOrWhiteSpace($redirectTarget) -and $attempted.Add($redirectTarget))
            {
                $queue.Enqueue($redirectTarget)
            }

            continue
        }

        return [pscustomobject]@{
            Title = $title
            Content = $content
            SourceUrl = Get-WikiPageUrl -Title $title
        }
    }

    throw "Wikipedia page was not found: $($Region.Titles -join ', ')"
}

function Split-WikiTemplateFields {
    param([string]$TemplateText)

    $text = $TemplateText.Trim()
    if ($text.StartsWith("{{", [System.StringComparison]::Ordinal))
    {
        $text = $text.Substring(2)
    }

    if ($text.EndsWith("}}", [System.StringComparison]::Ordinal))
    {
        $text = $text.Substring(0, $text.Length - 2)
    }

    $fields = [System.Collections.Generic.List[string]]::new()
    $builder = [System.Text.StringBuilder]::new()
    $braceDepth = 0
    $bracketDepth = 0

    for ($index = 0; $index -lt $text.Length; $index++)
    {
        if ($index + 1 -lt $text.Length)
        {
            $pair = $text.Substring($index, 2)
            if ($pair -eq "{{")
            {
                $braceDepth++
                [void]$builder.Append($pair)
                $index++
                continue
            }

            if ($pair -eq "}}")
            {
                if ($braceDepth -gt 0)
                {
                    $braceDepth--
                }

                [void]$builder.Append($pair)
                $index++
                continue
            }

            if ($pair -eq "[[")
            {
                $bracketDepth++
                [void]$builder.Append($pair)
                $index++
                continue
            }

            if ($pair -eq "]]")
            {
                if ($bracketDepth -gt 0)
                {
                    $bracketDepth--
                }

                [void]$builder.Append($pair)
                $index++
                continue
            }
        }

        if ($text[$index] -eq "|" -and $braceDepth -eq 0 -and $bracketDepth -eq 0)
        {
            $fields.Add($builder.ToString())
            [void]$builder.Clear()
            continue
        }

        [void]$builder.Append($text[$index])
    }

    $fields.Add($builder.ToString())
    return $fields
}

function Get-WikiTemplateParameters {
    param([string]$TemplateText)

    $parameters = [ordered]@{}
    $fields = Split-WikiTemplateFields -TemplateText $TemplateText
    foreach ($field in ($fields | Select-Object -Skip 1))
    {
        $separatorIndex = $field.IndexOf("=")
        if ($separatorIndex -lt 0)
        {
            continue
        }

        $name = $field.Substring(0, $separatorIndex).Trim()
        $value = $field.Substring($separatorIndex + 1).Trim()
        if (-not [string]::IsNullOrWhiteSpace($name))
        {
            $parameters[$name] = $value
        }
    }

    return $parameters
}

function Normalize-WikiText {
    param([string]$Value)

    if ([string]::IsNullOrWhiteSpace($Value))
    {
        return [string]::Empty
    }

    $text = $Value
    $text = [System.Text.RegularExpressions.Regex]::Replace($text, '(?is)<ref[^>]*>.*?</ref>', [string]::Empty)
    $text = [System.Text.RegularExpressions.Regex]::Replace($text, '(?is)<ref[^>]*/\s*>', [string]::Empty)
    $text = [System.Text.RegularExpressions.Regex]::Replace($text, '(?i)<br\s*/?>', ' ')
    $text = $text.Replace("&nbsp;", " ")

    while ($text -match '\[\[([^\]|]+)\|([^\]]+)\]\]')
    {
        $text = [System.Text.RegularExpressions.Regex]::Replace($text, '\[\[([^\]|]+)\|([^\]]+)\]\]', '$2')
    }

    $text = [System.Text.RegularExpressions.Regex]::Replace($text, '\[\[([^\]]+)\]\]', '$1')
    $text = [System.Text.RegularExpressions.Regex]::Replace($text, '\{\{(?:nowrap|small)\|([^{}]+)\}\}', '$1')
    $text = [System.Text.RegularExpressions.Regex]::Replace($text, '\{\{lang\|[^|]+\|([^{}]+)\}\}', '$1')

    while ($text -match '\{\{[^{}]+\}\}')
    {
        $text = [System.Text.RegularExpressions.Regex]::Replace($text, '\{\{[^{}]+\}\}', [string]::Empty)
    }

    $text = $text.Replace("'''", [string]::Empty)
    $text = $text.Replace("''", [string]::Empty)
    $text = [System.Text.RegularExpressions.Regex]::Replace($text, '\s+', ' ')
    return $text.Trim()
}

function Parse-PositiveInt {
    param([string]$Value)

    if ([string]::IsNullOrWhiteSpace($Value))
    {
        return 0
    }

    $digits = $Value -replace '[^0-9]', [string]::Empty
    if ([string]::IsNullOrWhiteSpace($digits))
    {
        return 0
    }

    return [int]$digits
}

function Get-ElectionSections {
    param([string]$Content)

    $sectionMatches = [System.Text.RegularExpressions.Regex]::Matches($Content, '(?m)^===\s*(?<heading>.+?)\s*===\s*$')
    $sections = @{}
    for ($index = 0; $index -lt $sectionMatches.Count; $index++)
    {
        $heading = $sectionMatches[$index].Groups["heading"].Value
        if ($heading -notmatch $electionHeadingPattern)
        {
            continue
        }

        $order = [int]$Matches["order"]
        $start = $sectionMatches[$index].Index + $sectionMatches[$index].Length
        $end = if ($index + 1 -lt $sectionMatches.Count) { $sectionMatches[$index + 1].Index } else { $Content.Length }
        $sections[$order] = $Content.Substring($start, $end - $start)
    }

    return $sections
}

function Convert-ToWinnerEntry {
    param(
        [int]$ElectionOrder,
        [string]$SectionText,
        [string]$SourceUrl
    )

    $winnerPattern = '\{\{' + [regex]::Escape($winnerTemplatePrefix) + '[^\n]*'
    $winnerMatch = [System.Text.RegularExpressions.Regex]::Match($SectionText, $winnerPattern)
    if (-not $winnerMatch.Success)
    {
        return $null
    }

    $parameters = Get-WikiTemplateParameters -TemplateText $winnerMatch.Value
    $name = Normalize-WikiText -Value (Get-FirstValue -Table $parameters -Keys @($candidateKey, $candidateNameKey))
    if ([string]::IsNullOrWhiteSpace($name))
    {
        return $null
    }

    return [ordered]@{
        ElectionOrder = $ElectionOrder
        Year = $yearByOrder[$ElectionOrder]
        Name = $name
        Party = Normalize-WikiText -Value (Get-FirstValue -Table $parameters -Keys @($partyKey, $partyNameKey))
        Note = Normalize-WikiText -Value (Get-FirstValue -Table $parameters -Keys @($noteKey))
        SourceUrl = $SourceUrl
    }
}

function Convert-ToTurnoutEntry {
    param(
        [int]$ElectionOrder,
        [string]$SectionText,
        [string]$SourceUrl
    )

    if ($ElectionOrder -lt 3)
    {
        return $null
    }

    $startPattern = '\{\{' + [regex]::Escape($turnoutStartTemplatePrefix) + '[^\n]*'
    $totalPattern = '\{\{' + [regex]::Escape($turnoutTotalTemplatePrefix) + '[^\n]*'
    $startMatch = [System.Text.RegularExpressions.Regex]::Match($SectionText, $startPattern)
    $totalMatch = [System.Text.RegularExpressions.Regex]::Match($SectionText, $totalPattern)
    if (-not $startMatch.Success -or -not $totalMatch.Success)
    {
        return $null
    }

    $startParameters = Get-WikiTemplateParameters -TemplateText $startMatch.Value
    $totalParameters = Get-WikiTemplateParameters -TemplateText $totalMatch.Value
    $electors = Parse-PositiveInt -Value (Get-FirstValue -Table $startParameters -Keys @($electorsKey, $totalElectorsKey))
    $votes = Parse-PositiveInt -Value (Get-FirstValue -Table $totalParameters -Keys @($sumKey, $totalVotesKey))
    if ($electors -le 0 -or $votes -le 0)
    {
        return $null
    }

    return [ordered]@{
        ElectionOrder = $ElectionOrder
        Year = $yearByOrder[$ElectionOrder]
        Electors = $electors
        Votes = $votes
        TurnoutRate = [Math]::Round(($votes * 100.0) / $electors, 1, [MidpointRounding]::AwayFromZero)
        SourceUrl = $SourceUrl
    }
}

if (-not [System.IO.Path]::IsPathRooted($OutputPath))
{
    $OutputPath = Join-Path $PSScriptRoot $OutputPath
}

$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$outputDirectory = Split-Path -Parent $OutputPath
if (-not [string]::IsNullOrWhiteSpace($outputDirectory) -and -not (Test-Path -LiteralPath $outputDirectory))
{
    [void](New-Item -ItemType Directory -Path $outputDirectory)
}

$sources = [System.Collections.Generic.List[object]]::new()
foreach ($source in $officialSources)
{
    $sources.Add($source)
}

$codeItems = [System.Collections.Generic.List[object]]::new()
$codePageNo = 1
$codePageSize = 100
while ($true)
{
    $codeXml = Invoke-NecXml -ServicePath "CommonCodeService/getCommonSgCodeList" -Parameters @{
        pageNo = $codePageNo
        numOfRows = $codePageSize
    }

    $pageItems = Get-XmlItems -Xml $codeXml
    foreach ($pageItem in $pageItems)
    {
        $codeItems.Add($pageItem)
    }

    $totalCount = Parse-PositiveInt -Value ([string]$codeXml.response.body.totalCount)
    if ($pageItems.Count -lt $codePageSize -or ($codePageNo * $codePageSize) -ge $totalCount)
    {
        break
    }

    $codePageNo++
}

$localElectionCycles = Get-LocalElectionCycles -CodeItems $codeItems -SgTypeCode "0"
$basicCycles = @($localElectionCycles)
$educationCycles = @($localElectionCycles | Where-Object { $_.Order -ge 5 })

$winnerCache = @{}
$turnoutCache = @{}

function Get-WinnerItems {
    param(
        [string]$SgId,
        [string]$SgTypeCode,
        [string]$SdName
    )

    $cacheKey = "$SgId|$SgTypeCode|$SdName"
    if (-not $winnerCache.ContainsKey($cacheKey))
    {
        $winnerCache[$cacheKey] = Get-XmlItems -Xml (Invoke-NecXml -ServicePath "WinnerInfoInqireService2/getWinnerInfoInqire" -Parameters @{
            sgId = $SgId
            sgTypecode = $SgTypeCode
            sdName = $SdName
            pageNo = 1
            numOfRows = 400
        })
    }

    return @($winnerCache[$cacheKey])
}

function Get-TurnoutItems {
    param(
        [string]$SgId,
        [string]$SdName
    )

    $cacheKey = "$SgId|$SdName"
    if (-not $turnoutCache.ContainsKey($cacheKey))
    {
        $turnoutCache[$cacheKey] = Get-XmlItems -Xml (Invoke-NecXml -ServicePath "VoteXmntckInfoInqireService2/getVoteSttusInfoInqire" -Parameters @{
            sgId = $SgId
            sgTypecode = 3
            sdName = $SdName
            pageNo = 1
            numOfRows = 400
        })
    }

    return @($turnoutCache[$cacheKey])
}

function Get-WinnerItemsForRegion {
    param(
        [pscustomobject]$Cycle,
        [string]$SgTypeCode,
        [object]$Region
    )

    foreach ($apiName in (Get-RegionApiNames -Region $Region))
    {
        $items = @(Get-WinnerItems -SgId $Cycle.SgId -SgTypeCode $SgTypeCode -SdName $apiName)
        if ($items.Count -gt 0)
        {
            return @($items)
        }
    }

    return @()
}

function Get-TurnoutItemsForRegion {
    param(
        [pscustomobject]$Cycle,
        [object]$Region
    )

    foreach ($apiName in (Get-RegionApiNames -Region $Region))
    {
        $items = @(Get-TurnoutItems -SgId $Cycle.SgId -SdName $apiName)
        if ($items.Count -gt 0)
        {
            return @($items)
        }
    }

    return @()
}

$records = [System.Collections.Generic.List[object]]::new()

foreach ($region in $regions)
{
    $regionKey = Decode-Text $region.Key
    $regionDisplayName = Decode-Text $region.DisplayName

    Write-Host ("Building governor history: {0}" -f $regionDisplayName)
    $page = Get-WikiPage -Region $region
    $sources.Add((New-SourceReference -Title $page.Title -Url $page.SourceUrl))

    $sections = Get-ElectionSections -Content $page.Content
    $record = New-HistoryRecord -ElectionType $governorType -Key $regionKey -RegionKey $regionKey -RegionName $regionDisplayName -DistrictName $regionDisplayName -DisplayName $regionDisplayName

    foreach ($order in 1..8)
    {
        if (-not $sections.ContainsKey($order))
        {
            continue
        }

        Add-HistoryEntry -Target $record.WinnerHistory -Entry (Convert-ToWinnerEntry -ElectionOrder $order -SectionText $sections[$order] -SourceUrl $page.SourceUrl)
        Add-HistoryEntry -Target $record.TurnoutHistory -Entry (Convert-ToTurnoutEntry -ElectionOrder $order -SectionText $sections[$order] -SourceUrl $page.SourceUrl)
    }

    $records.Add((Convert-RecordForJson -Record $record))
}

foreach ($region in $regions)
{
    $regionKey = Decode-Text $region.Key
    $regionDisplayName = Decode-Text $region.DisplayName
    Write-Host ("Building education history: {0}" -f $regionDisplayName)

    $record = New-HistoryRecord -ElectionType $educationType -Key $regionKey -RegionKey $regionKey -RegionName $regionDisplayName -DistrictName $regionDisplayName -DisplayName $regionDisplayName
    foreach ($cycle in $educationCycles)
    {
        $winnerItems = Get-WinnerItemsForRegion -Cycle $cycle -SgTypeCode "11" -Region $region
        $winnerItem = $winnerItems | Where-Object { -not [string]::IsNullOrWhiteSpace(([string]$_.name)) } | Select-Object -First 1
        Add-HistoryEntry -Target $record.WinnerHistory -Entry (New-OfficialWinnerEntry -Cycle $cycle -Item $winnerItem -SourceUrl $winnerSourceUrl)

        $turnoutItems = Get-TurnoutItemsForRegion -Cycle $cycle -Region $region
        $totalItem = $turnoutItems | Where-Object { [string]$_.wiwName -eq $totalLabel } | Select-Object -First 1
        if ($null -ne $totalItem)
        {
            $electors = Parse-PositiveInt -Value ([string]$totalItem.totSunsu)
            $votes = Parse-PositiveInt -Value ([string]$totalItem.totTusu)
            Add-HistoryEntry -Target $record.TurnoutHistory -Entry (New-OfficialTurnoutEntry -Cycle $cycle -Electors $electors -Votes $votes -SourceUrl $turnoutSourceUrl)
        }
    }

    $records.Add((Convert-RecordForJson -Record $record))
}

foreach ($region in $regions)
{
    $regionKey = Decode-Text $region.Key
    $regionDisplayName = Decode-Text $region.DisplayName
    Write-Host ("Building basic local history: {0}" -f $regionDisplayName)

    $basicRecordsByKey = @{}
    foreach ($cycle in $basicCycles)
    {
        $winnerItems = Get-WinnerItemsForRegion -Cycle $cycle -SgTypeCode "4" -Region $region
        $turnoutItems = Get-TurnoutItemsForRegion -Cycle $cycle -Region $region
        $turnoutDetails = @($turnoutItems | Where-Object { [string]$_.wiwName -ne $totalLabel })

        foreach ($winnerItem in $winnerItems)
        {
            $districtName = ([string]$winnerItem.sggName).Trim()
            if ([string]::IsNullOrWhiteSpace($districtName))
            {
                $districtName = ([string]$winnerItem.wiwName).Trim()
            }

            if ([string]::IsNullOrWhiteSpace($districtName))
            {
                continue
            }

            $rawDistrictName = $districtName
            $districtName = Normalize-BasicDistrictDisplayName -DistrictName $districtName -DisplayName ([string]$winnerItem.wiwName) -RegionName $regionDisplayName
            $districtKey = Normalize-BasicDistrictToken -Value $districtName
            if ([string]::IsNullOrWhiteSpace($districtKey))
            {
                continue
            }

            $recordKey = "$regionKey|$districtKey"
            if (-not $basicRecordsByKey.ContainsKey($recordKey))
            {
                $basicRecordsByKey[$recordKey] = New-HistoryRecord `
                    -ElectionType $basicType `
                    -Key $recordKey `
                    -RegionKey $regionKey `
                    -RegionName $regionDisplayName `
                    -DistrictName $districtName `
                    -DisplayName "$regionDisplayName $districtName"
            }

            $record = $basicRecordsByKey[$recordKey]
            Add-HistoryEntry -Target $record.WinnerHistory -Entry (New-OfficialWinnerEntry -Cycle $cycle -Item $winnerItem -SourceUrl $winnerSourceUrl)

            $turnoutSnapshot = Resolve-BasicTurnoutSnapshot -DistrictName $rawDistrictName -TurnoutItems $turnoutDetails
            if ($null -ne $turnoutSnapshot)
            {
                Add-HistoryEntry -Target $record.TurnoutHistory -Entry (New-OfficialTurnoutEntry -Cycle $cycle -Electors $turnoutSnapshot.Electors -Votes $turnoutSnapshot.Votes -SourceUrl $turnoutSourceUrl)
            }
        }
    }

    foreach ($record in ($basicRecordsByKey.Values | Sort-Object DisplayName))
    {
        $records.Add((Convert-RecordForJson -Record $record))
    }
}

$deduplicatedSources = @($sources | Group-Object Url | ForEach-Object { $_.Group[0] } | Sort-Object Url)
$coverageNotes = @(
    (Decode-Text "%EC%A0%84%EA%B5%AD%2017%EA%B0%9C%20%EC%8B%9C%EB%8F%84%20%EA%B4%91%EC%97%AD%EB%8B%A8%EC%B2%B4%EC%9E%A5%C2%B7%EA%B5%90%EC%9C%A1%EA%B0%90%20%EA%B8%B0%EC%A4%80%20%EC%A0%80%EC%9E%A5%ED%98%95%20%EC%82%AC%EC%A0%84%20%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%9E%85%EB%8B%88%EB%8B%A4."),
    (Decode-Text "%EA%B8%B0%EC%B4%88%EB%8B%A8%EC%B2%B4%EC%9E%A5%EC%9D%80%20%EC%A4%91%EC%95%99%EC%84%A0%EA%B1%B0%EA%B4%80%EB%A6%AC%EC%9C%84%EC%9B%90%ED%9A%8C%20OpenAPI%20%EA%B8%B0%EC%A4%80%20%EC%A0%84%EA%B5%AD%20%EC%84%A0%EA%B1%B0%EA%B5%AC%EB%A5%BC%20%EB%AF%B8%EB%A6%AC%20%EC%A0%80%EC%9E%A5%ED%95%A9%EB%8B%88%EB%8B%A4."),
    (Decode-Text "%EA%B4%91%EC%97%AD%EB%8B%A8%EC%B2%B4%EC%9E%A5%20%EB%8B%B9%EC%84%A0%EC%9E%90%EB%8A%94%201995%EB%85%84%EB%B6%80%ED%84%B0%202022%EB%85%84%EA%B9%8C%EC%A7%80,%20%ED%88%AC%ED%91%9C%EC%9C%A8%EC%9D%80%202002%EB%85%84%EB%B6%80%ED%84%B0%202022%EB%85%84%EA%B9%8C%EC%A7%80%20%EC%A0%80%EC%9E%A5%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4."),
    (Decode-Text "%EA%B5%90%EC%9C%A1%EA%B0%90%EC%9D%80%20%EC%A0%84%EA%B5%AD%20%EC%A7%81%EC%84%A0%EC%A0%9C%EA%B0%80%20%EC%A0%81%EC%9A%A9%EB%90%9C%202010%EB%85%84%EB%B6%80%ED%84%B0%202022%EB%85%84%EA%B9%8C%EC%A7%80%20%EC%A0%80%EC%9E%A5%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4."),
    (Decode-Text "%EA%B8%B0%EC%B4%88%EB%8B%A8%EC%B2%B4%EC%9E%A5%EC%9D%98%20%EB%B6%84%ED%95%A0%20%EC%8B%9C%20%EC%84%A0%EA%B1%B0%EA%B5%AC(%EC%88%98%EC%9B%90%EC%8B%9C%C2%B7%EC%B0%BD%EC%9B%90%EC%8B%9C%20%EB%93%B1)%EB%8A%94%20%EB%8F%99%EC%9D%BC%20%EC%8B%9C%20%EB%8B%A8%EC%9C%84%EB%A1%9C%20%ED%88%AC%ED%91%9C%EC%9C%A8%EC%9D%84%20%ED%95%A9%EC%82%B0%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4.")
)

$catalog = [ordered]@{
    Metadata = [ordered]@{
        Version = "2026-04-18-national-history-mixed1"
        GeneratedAt = (Get-Date -Format "yyyy-MM-dd")
        CoverageNotes = $coverageNotes
        Sources = $deduplicatedSources
    }
    Records = @($records)
}

$json = $catalog | ConvertTo-Json -Depth 12
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($OutputPath, $json, $utf8NoBom)

$jsonObject = $json | ConvertFrom-Json
$counts = $jsonObject.Records | Group-Object ElectionType | Sort-Object Name
Write-Host ("Saved pre-election history seed to {0}" -f $OutputPath)
foreach ($count in $counts)
{
    Write-Host ("{0}: {1}" -f $count.Name, $count.Count)
}
