using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Tornado3_2026Election.Domain;

namespace Tornado3_2026Election.Services;

public sealed class SbsElectionApiClient : IDisposable
{
    private const string BasicApiBaseUrlEnvironmentVariable = "SBS_BASIC_API_BASE_URL";
    private const string BasicApiModeEnvironmentVariable = "SBS_BASIC_API_MODE";
    private static readonly TimeSpan ApiResponseCacheDuration = TimeSpan.FromMinutes(1);
    private static readonly TimeSpan BasicCouncilCountingCacheDuration = ApiResponseCacheDuration;
    private static readonly Uri LegacyBaseUri = new("http://202.31.153.141:8421/");
    private static readonly Uri BasicApiBaseUri = ResolveBasicApiBaseUri();
    private static readonly JsonSerializerOptions SerializerOptions = new()
    {
        PropertyNameCaseInsensitive = true,
        NumberHandling = JsonNumberHandling.AllowReadingFromString
    };

    private static readonly IReadOnlyDictionary<string, SbsElectionConfiguration> ElectionConfigurations =
        new Dictionary<string, SbsElectionConfiguration>(StringComparer.Ordinal)
        {
            ["국회의원"] = new SbsElectionConfiguration(2, false, LegacyBaseUri, "gaepyo"),
            ["광역단체장"] = new SbsElectionConfiguration(3, true, LegacyBaseUri, "gaepyo"),
            ["교육감"] = new SbsElectionConfiguration(11, true, LegacyBaseUri, "gaepyo"),
            ["광역의원"] = new SbsElectionConfiguration(5, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment()),
            ["기초단체장"] = new SbsElectionConfiguration(4, true, LegacyBaseUri, "gaepyo"),
            ["기초의원"] = new SbsElectionConfiguration(6, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment())
        };

    private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
        new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["서울"] = "서울특별시",
            ["부산"] = "부산광역시",
            ["대구"] = "대구광역시",
            ["인천"] = "인천광역시",
            ["광주"] = "광주광역시",
            ["대전"] = "대전광역시",
            ["울산"] = "울산광역시",
            ["세종"] = "세종특별자치시",
            ["경기"] = "경기도",
            ["강원"] = "강원특별자치도",
            ["충북"] = "충청북도",
            ["충남"] = "충청남도",
            ["전북"] = "전북특별자치도",
            ["전남"] = "전라남도",
            ["경북"] = "경상북도",
            ["경남"] = "경상남도",
            ["제주"] = "제주특별자치도"
        };

    private static readonly IReadOnlyDictionary<string, string> BasicApiSidoCodes =
        new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["서울"] = "11",
            ["서울특별시"] = "11",
            ["부산"] = "26",
            ["부산광역시"] = "26",
            ["대구"] = "27",
            ["대구광역시"] = "27",
            ["인천"] = "28",
            ["인천광역시"] = "28",
            ["광주"] = "29",
            ["광주광역시"] = "29",
            ["전남광주"] = "29",
            ["광주전남"] = "29",
            ["대전"] = "30",
            ["대전광역시"] = "30",
            ["울산"] = "31",
            ["울산광역시"] = "31",
            ["세종"] = "36",
            ["세종특별자치시"] = "36",
            ["경기"] = "41",
            ["경기도"] = "41",
            ["충북"] = "43",
            ["충청북도"] = "43",
            ["충남"] = "44",
            ["충청남도"] = "44",
            ["전남"] = "46",
            ["전라남도"] = "46",
            ["경북"] = "47",
            ["경상북도"] = "47",
            ["경남"] = "48",
            ["경상남도"] = "48",
            ["제주"] = "49",
            ["제주도"] = "49",
            ["제주특별자치도"] = "49",
            ["강원"] = "52",
            ["강원도"] = "52",
            ["강원특별자치도"] = "52",
            ["전북"] = "53",
            ["전라북도"] = "53",
            ["전북특별자치도"] = "53"
        };

    private readonly HttpClient _httpClient;
    private readonly bool _disposeHttpClient;
    private IReadOnlyList<SbsRegionInfo>? _sidoRegions;
    private readonly Dictionary<string, IReadOnlyList<SbsRegionInfo>> _districtRegions = new(StringComparer.Ordinal);
    private readonly Dictionary<string, JsonCacheEntry> _jsonCache = new(StringComparer.Ordinal);
    private readonly SemaphoreSlim _jsonCacheLock = new(1, 1);
    private readonly Dictionary<string, SbsCountingCacheEntry> _countingCache = new(StringComparer.Ordinal);
    private readonly SemaphoreSlim _countingCacheLock = new(1, 1);

    public SbsElectionApiClient(HttpClient? httpClient = null)
    {
        _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
        _disposeHttpClient = httpClient is null;
    }

    public async Task<SbsElectionRefreshResult> RefreshAsync(
        BroadcastPhase phase,
        string electionType,
        string districtName,
        string districtCode,
        CancellationToken cancellationToken)
    {
        if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
        {
            throw new InvalidOperationException(
                $"'{electionType}'은 현재 SBS API 실연동 범위에 없습니다. 현재는 국회의원, 광역단체장, 교육감, 광역의원, 기초단체장, 기초의원만 연결되어 있습니다.");
        }

        return phase switch
        {
            BroadcastPhase.PreElection => await RefreshTurnoutAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false),
            BroadcastPhase.Counting => await RefreshCountingAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false),
            _ => throw new InvalidOperationException($"지원하지 않는 방송 단계입니다: {phase}")
        };
    }

    public async Task<IReadOnlyList<DistrictSelectionOption>> GetDistrictOptionsAsync(
        string electionType,
        CancellationToken cancellationToken)
    {
        return await GetDistrictOptionsAsync(
            electionType,
            Array.Empty<string>(),
            cancellationToken).ConfigureAwait(false);
    }

    public async Task<IReadOnlyList<DistrictSelectionOption>> GetDistrictOptionsAsync(
        string electionType,
        IEnumerable<string> regionFilters,
        CancellationToken cancellationToken)
    {
        if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
        {
            return Array.Empty<DistrictSelectionOption>();
        }

        var scopedSidoCodes = ResolveBasicApiSidoCodes(regionFilters);
        var regions = await GetElectionDistrictRegionsAsync(configuration, scopedSidoCodes, cancellationToken).ConfigureAwait(false);
        var options = regions
            .Select((region, index) => new
            {
                Region = region,
                Index = index,
                Option = CreateDistrictSelectionOption(configuration.SungerType, region)
            })
            .Where(item => !string.IsNullOrWhiteSpace(item.Option.DisplayName));

        if (configuration.SungerType == 11)
        {
            return options
                .Select(item => item.Option)
                .ToArray();
        }

        if (configuration.SungerType is 2 or 3 or 4 or 5 or 6)
        {
            return options
                .OrderBy(item => item.Region.Order > 0 ? item.Region.Order : int.MaxValue)
                .ThenBy(item => item.Index)
                .Select(item => item.Option)
                .ToArray();
        }

        return options
            .OrderBy(item => item.Option.RegionName, StringComparer.Ordinal)
            .ThenBy(item => item.Option.DistrictName, StringComparer.Ordinal)
            .Select(item => item.Option)
            .ToArray();
    }

    public async Task<IReadOnlyList<CountingOverviewItem>> GetCountingOverviewAsync(
        string electionType,
        IReadOnlyList<DistrictSelectionOption> districts,
        CancellationToken cancellationToken)
    {
        if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || districts.Count == 0)
        {
            return Array.Empty<CountingOverviewItem>();
        }

        var requestedDistricts = districts
            .Where(district => !string.IsNullOrWhiteSpace(district.DistrictCode))
            .GroupBy(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase)
            .Select(group => group.First())
            .ToArray();

        if (requestedDistricts.Length == 0)
        {
            return Array.Empty<CountingOverviewItem>();
        }

        var orderMap = requestedDistricts
            .Select((district, index) => new { district.DistrictCode, Index = index })
            .ToDictionary(item => item.DistrictCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
        var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
        var overviewItems = new List<(int Order, CountingOverviewItem Item)>();

        var countingItems = await GetCountingItemsForDistrictsAsync(
            configuration,
            requestedDistricts,
            cancellationToken).ConfigureAwait(false);

        foreach (var responseItem in countingItems)
        {
            var item = responseItem.Item;
            var regionId = item.Region?.Id;
            if (string.IsNullOrWhiteSpace(regionId) ||
                !districtMap.TryGetValue(regionId, out var districtOption) ||
                !orderMap.TryGetValue(regionId, out var order))
            {
                continue;
            }

            var totalVotes = Math.Max(0, item.Total?.Tupyosu ?? 0);
            var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
            var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
            var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
            var seatCount = Math.Max(0, item.Region?.SeatCount ?? 0);
            var countingClosed = item.GaepyoMagam;
            var judgementCandidates = (item.Hubojas ?? [])
                .Select(candidate => MapCandidate(candidate, seatCount, countingClosed))
                .Where(candidate => candidate.EffectiveJudgement != CandidateJudgement.None)
                .OrderBy(candidate => ResolveJudgementDisplayPriority(candidate.EffectiveJudgement))
                .ThenByDescending(candidate => candidate.VoteCount)
                .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
                .ToArray();

            overviewItems.Add((order, new CountingOverviewItem(
                DisplayName: districtOption.DisplayName,
                DistrictCode: districtOption.DistrictCode,
                CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
                CountedVotes: countedVotes,
                TotalVotes: totalVotes,
                UncountedVotes: Math.Max(0, uncountedVotes),
                JudgementBadgeText: BuildOverviewJudgementBadgeText(judgementCandidates),
                JudgementDetailText: BuildOverviewJudgementDetailText(judgementCandidates))));
        }

        return overviewItems
            .OrderBy(item => item.Order)
            .Select(item => item.Item)
            .ToArray();
    }

    public async Task<IReadOnlyList<SbsElectionRefreshResult>> GetCountingSnapshotsAsync(
        string electionType,
        IReadOnlyList<DistrictSelectionOption> districts,
        CancellationToken cancellationToken)
    {
        if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || districts.Count == 0)
        {
            return Array.Empty<SbsElectionRefreshResult>();
        }

        var requestedDistricts = districts
            .Where(district => !string.IsNullOrWhiteSpace(district.DistrictCode))
            .GroupBy(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase)
            .Select(group => group.First())
            .ToArray();
        if (requestedDistricts.Length == 0)
        {
            return Array.Empty<SbsElectionRefreshResult>();
        }

        var orderMap = requestedDistricts
            .Select((district, index) => new { district.DistrictCode, Index = index })
            .ToDictionary(item => item.DistrictCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
        var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
        var results = new List<(int Order, SbsElectionRefreshResult Result)>();

        var countingItems = await GetCountingItemsForDistrictsAsync(
            configuration,
            requestedDistricts,
            cancellationToken).ConfigureAwait(false);

        foreach (var responseItem in countingItems)
        {
            var item = responseItem.Item;
            var regionId = item.Region?.Id;
            if (string.IsNullOrWhiteSpace(regionId) ||
                !districtMap.TryGetValue(regionId, out var districtOption) ||
                !orderMap.TryGetValue(regionId, out var order))
            {
                continue;
            }

            results.Add((order, CreateCountingRefreshResult(
                configuration,
                districtOption,
                item,
                responseItem.SourcePath)));
        }

        return results
            .OrderBy(item => item.Order)
            .Select(item => item.Result)
            .ToArray();
    }

    public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync(
        string electionType,
        IReadOnlyList<DistrictSelectionOption> districts,
        CancellationToken cancellationToken)
    {
        if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
            !configuration.SupportsPreElection ||
            districts.Count == 0)
        {
            return new TurnoutOverviewResult(
                Array.Empty<TurnoutOverviewItem>(),
                0,
                0,
                DateTimeOffset.Now);
        }

        var turnoutQuery = ResolveTurnoutQuery(configuration);
        if (!turnoutQuery.HasValue)
        {
            return new TurnoutOverviewResult(
                Array.Empty<TurnoutOverviewItem>(),
                0,
                0,
                DateTimeOffset.Now);
        }

        var turnoutQueryValue = turnoutQuery.Value;
        var requestedDistricts = districts
            .Select(district => new
            {
                District = district,
                RegionCode = ResolveTurnoutRegionCode(configuration, district)
            })
            .Where(item => !string.IsNullOrWhiteSpace(item.RegionCode))
            .GroupBy(item => item.RegionCode, StringComparer.OrdinalIgnoreCase)
            .Select(group => group.First())
            .ToArray();

        if (requestedDistricts.Length == 0)
        {
            return new TurnoutOverviewResult(
                Array.Empty<TurnoutOverviewItem>(),
                0,
                0,
                DateTimeOffset.Now);
        }

        var orderMap = requestedDistricts
            .Select((item, index) => new { item.RegionCode, Index = index })
            .ToDictionary(item => item.RegionCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
        var districtMap = requestedDistricts.ToDictionary(item => item.RegionCode, item => item.District, StringComparer.OrdinalIgnoreCase);
        var turnoutItems = new List<(int Order, TurnoutOverviewItem Item)>();
        var totalExpectedVotes = 0;
        var turnoutVotes = 0;

        foreach (var districtChunk in requestedDistricts.Chunk(24))
        {
            var ids = string.Join(",", districtChunk.Select(item => item.RegionCode));
            var items = await GetArrayAsync<SbsTurnoutItem>(
                configuration.BaseUri,
                $"tupyo/{turnoutQueryValue.SungerType}/{turnoutQueryValue.RegionSegment}?ids={ids}",
                cancellationToken).ConfigureAwait(false);

            foreach (var item in items)
            {
                var electors = Math.Max(0, item.Sungerinsu);
                var voters = Math.Max(0, item.Total?.Tupyosu ?? 0);
                totalExpectedVotes += electors;
                turnoutVotes += voters;

                var regionId = item.Region?.Id;
                if (string.IsNullOrWhiteSpace(regionId) ||
                    !districtMap.TryGetValue(regionId, out var districtOption) ||
                    !orderMap.TryGetValue(regionId, out var order))
                {
                    continue;
                }

                var turnoutRate = electors <= 0
                    ? 0
                    : Math.Round(voters * 100d / electors, 1, MidpointRounding.AwayFromZero);
                var referenceTimeLabel = FormatSbsReportTimeLabel(item.LastReportTime);

                turnoutItems.Add((order, new TurnoutOverviewItem(
                    districtOption.DisplayName,
                    districtOption.RegionName,
                    districtOption.DistrictName,
                    districtOption.DistrictCode,
                    electors,
                    voters,
                    turnoutRate,
                    referenceTimeLabel)));
            }
        }

        var overviewReferenceTimeLabel = ResolveLatestReferenceTimeLabel(turnoutItems.Select(item => item.Item.ReferenceTimeLabel));
        return new TurnoutOverviewResult(
            turnoutItems
                .OrderBy(item => item.Order)
                .Select(item => item.Item)
                .ToArray(),
            totalExpectedVotes,
            turnoutVotes,
            DateTimeOffset.Now,
            overviewReferenceTimeLabel);
    }

    private static string ResolveTurnoutRegionCode(
        SbsElectionConfiguration configuration,
        DistrictSelectionOption district)
    {
        if (!string.IsNullOrWhiteSpace(district.TurnoutRegionCode))
        {
            return district.TurnoutRegionCode;
        }

        if (configuration.SungerType == 4)
        {
            return string.Empty;
        }

        return !string.IsNullOrWhiteSpace(district.ParentRegionCode)
            ? district.ParentRegionCode
            : district.DistrictCode;
    }

    public void Dispose()
    {
        _jsonCacheLock.Dispose();
        _countingCacheLock.Dispose();
        if (_disposeHttpClient)
        {
            _httpClient.Dispose();
        }
    }

    private async Task<SbsElectionRefreshResult> RefreshTurnoutAsync(
        SbsElectionConfiguration configuration,
        string districtName,
        string districtCode,
        CancellationToken cancellationToken)
    {
        if (!configuration.SupportsPreElection)
        {
            throw new InvalidOperationException(
                "선택한 선거 종류는 SBS API 문서 기준으로 투표율 연동 대상이 아닙니다.");
        }

        var turnoutQuery = ResolveTurnoutQuery(configuration)
            ?? throw new InvalidOperationException("선택한 선거 종류는 투표율 연동 경로가 없습니다.");
        var turnoutTarget = await ResolveTurnoutTargetAsync(
            configuration,
            districtName,
            districtCode,
            cancellationToken).ConfigureAwait(false);
        var items = await GetArrayAsync<SbsTurnoutItem>(
            configuration.BaseUri,
            $"tupyo/{turnoutQuery.SungerType}/{turnoutQuery.RegionSegment}?ids={Uri.EscapeDataString(turnoutTarget.TurnoutRegionCode)}",
            cancellationToken).ConfigureAwait(false);

        var item = items.FirstOrDefault(candidate =>
                string.Equals(candidate.Region?.Id, turnoutTarget.TurnoutRegionCode, StringComparison.OrdinalIgnoreCase))
            ?? items.FirstOrDefault()
            ?? throw new InvalidOperationException("SBS API가 해당 지역의 투표 데이터를 반환하지 않았습니다.");

        return new SbsElectionRefreshResult(
            DistrictName: turnoutTarget.DisplayName,
            DistrictCode: turnoutTarget.DistrictCode,
            RegionName: turnoutTarget.RegionName,
            ElectionDistrictName: turnoutTarget.ElectionDistrictName,
            TotalExpectedVotes: item.Sungerinsu,
            TurnoutVotes: item.Total?.Tupyosu ?? 0,
            CountedRate: null,
            CountedVotes: null,
            RemainingVotes: null,
            SeatCount: 0,
            CountingClosed: false,
            Candidates: null,
            ReceivedAt: DateTimeOffset.Now,
            SourcePath: $"GET /tupyo/{turnoutQuery.SungerType}/{turnoutQuery.RegionSegment}?ids={turnoutTarget.TurnoutRegionCode}",
            ReferenceTimeLabel: FormatSbsReportTimeLabel(item.LastReportTime));
    }

    private static TurnoutQueryDefinition? ResolveTurnoutQuery(SbsElectionConfiguration configuration)
    {
        return configuration.SungerType switch
        {
            3 => new TurnoutQueryDefinition(3, "sidos"),
            4 => new TurnoutQueryDefinition(3, "sigungus"),
            11 => new TurnoutQueryDefinition(3, "sidos"),
            _ => null
        };
    }

    private static string ResolveLatestReferenceTimeLabel(IEnumerable<string> referenceTimeLabels)
    {
        return referenceTimeLabels
            .Select(label => label?.Trim() ?? string.Empty)
            .Where(label => !string.IsNullOrWhiteSpace(label))
            .Select(label => new
            {
                Label = label,
                SortValue = TryParseReferenceTimeLabel(label, out var totalMinutes) ? totalMinutes : -1
            })
            .OrderByDescending(item => item.SortValue)
            .ThenByDescending(item => item.Label, StringComparer.Ordinal)
            .Select(item => item.Label)
            .FirstOrDefault() ?? string.Empty;
    }

    private static string FormatSbsReportTimeLabel(double? lastReportTime)
    {
        if (!lastReportTime.HasValue || double.IsNaN(lastReportTime.Value) || double.IsInfinity(lastReportTime.Value))
        {
            return string.Empty;
        }

        var value = lastReportTime.Value;
        int hour;
        int minute;
        if (value >= 100d)
        {
            var compactTime = (int)Math.Round(value, MidpointRounding.AwayFromZero);
            hour = compactTime / 100;
            minute = compactTime % 100;
        }
        else
        {
            var totalMinutes = (int)Math.Round(value * 60d, MidpointRounding.AwayFromZero);
            hour = totalMinutes / 60;
            minute = totalMinutes % 60;
        }

        hour = ((hour % 24) + 24) % 24;
        minute = Math.Clamp(minute, 0, 59);
        return minute == 0
            ? FormattableString.Invariant($"{hour}시 기준")
            : FormattableString.Invariant($"{hour}시 {minute:00}분 기준");
    }

    private static bool TryParseReferenceTimeLabel(string label, out int totalMinutes)
    {
        totalMinutes = -1;
        if (string.IsNullOrWhiteSpace(label))
        {
            return false;
        }

        var digits = new string(label.Where(char.IsDigit).ToArray());
        if (digits.Length == 0 || !int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
        {
            return false;
        }

        if (digits.Length <= 2)
        {
            totalMinutes = value * 60;
            return true;
        }

        var hour = value / 100;
        var minute = value % 100;
        totalMinutes = hour * 60 + minute;
        return true;
    }

    private async Task<TurnoutTarget> ResolveTurnoutTargetAsync(
        SbsElectionConfiguration configuration,
        string districtName,
        string districtCode,
        CancellationToken cancellationToken)
    {
        if (configuration.SungerType == 3)
        {
            var sido = await ResolveSidoRegionAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false);
            var regionName = ExpandRegionName(sido.Name1 ?? sido.Name);
            return new TurnoutTarget(
                DisplayName: regionName,
                DistrictCode: sido.Id,
                RegionName: BuildOutputRegionName(regionName),
                ElectionDistrictName: regionName,
                TurnoutRegionCode: !string.IsNullOrWhiteSpace(sido.Name1Id) ? sido.Name1Id : sido.Id);
        }

        var district = await ResolveElectionDistrictAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false);
        var districtOption = CreateDistrictSelectionOption(configuration.SungerType, district);
        var turnoutRegionCode = configuration.SungerType switch
        {
            4 => district.Name2Id,
            11 => district.Name1Id,
            _ => districtOption.TurnoutRegionCode
        };

        if (string.IsNullOrWhiteSpace(turnoutRegionCode))
        {
            throw new InvalidOperationException($"투표율 조회용 지역 코드를 찾지 못했습니다: '{districtName}'");
        }

        return new TurnoutTarget(
            DisplayName: districtOption.DisplayName,
            DistrictCode: districtOption.DistrictCode,
            RegionName: districtOption.RegionName,
            ElectionDistrictName: districtOption.DistrictName,
            TurnoutRegionCode: turnoutRegionCode);
    }

    private async Task<SbsElectionRefreshResult> RefreshCountingAsync(
        SbsElectionConfiguration configuration,
        string districtName,
        string districtCode,
        CancellationToken cancellationToken)
    {
        if (CanQueryBasicCouncilByDistrictId(configuration) &&
            !string.IsNullOrWhiteSpace(districtCode))
        {
            return await RefreshBasicCouncilCountingByDistrictIdAsync(
                configuration,
                districtName,
                districtCode,
                cancellationToken).ConfigureAwait(false);
        }

        var district = await ResolveElectionDistrictAsync(
            configuration,
            districtName,
            districtCode,
            cancellationToken).ConfigureAwait(false);

        var path = BuildCountingPath(configuration, $"ids={Uri.EscapeDataString(district.Id)}");
        var items = await GetArrayAsync<SbsCountingItem>(
            configuration.BaseUri,
            path,
            cancellationToken).ConfigureAwait(false);

        var item = items.FirstOrDefault()
            ?? throw new InvalidOperationException("SBS API가 해당 지역의 개표 데이터를 반환하지 않았습니다.");
        var result = CreateCountingRefreshResult(
            configuration,
            CreateDistrictSelectionOption(configuration.SungerType, district),
            item,
            $"GET /{path}");

        if (result.Candidates is null || result.Candidates.Count == 0)
        {
            throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
        }

        return result;
    }

    private async Task<SbsElectionRefreshResult> RefreshBasicCouncilCountingByDistrictIdAsync(
        SbsElectionConfiguration configuration,
        string districtName,
        string districtCode,
        CancellationToken cancellationToken)
    {
        var path = BuildCountingPath(configuration, $"ids={Uri.EscapeDataString(districtCode)}");
        var items = await GetCountingItemsForPathAsync(
            configuration,
            path,
            cancellationToken).ConfigureAwait(false);

        var item = items.FirstOrDefault().Item
            ?? throw new InvalidOperationException("SBS API가 해당 지역의 개표 데이터를 반환하지 않았습니다.");
        var region = CreateRegionInfo(item.Region) ?? new SbsRegionInfo
        {
            Id = districtCode,
            Name = districtName,
            Name1 = districtName,
            Name4 = districtName,
            Name1Id = ResolveBasicApiSidoCode(districtName)
        };
        var result = CreateCountingRefreshResult(
            configuration,
            CreateDistrictSelectionOption(configuration.SungerType, region),
            item,
            $"GET /{path}");

        if (result.Candidates is null || result.Candidates.Count == 0)
        {
            throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
        }

        return result;
    }

    private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsForDistrictsAsync(
        SbsElectionConfiguration configuration,
        IReadOnlyList<DistrictSelectionOption> districts,
        CancellationToken cancellationToken)
    {
        if (CanQueryCountingBySido(configuration, districts))
        {
            return await GetCountingItemsBySidoAsync(configuration, districts, cancellationToken).ConfigureAwait(false);
        }

        var results = new List<SbsCountingResponseItem>();
        foreach (var districtChunk in districts.Chunk(24))
        {
            var ids = string.Join(",", districtChunk.Select(district => Uri.EscapeDataString(district.DistrictCode)));
            var path = BuildCountingPath(configuration, $"ids={ids}");
            results.AddRange(await GetCountingItemsForPathAsync(
                configuration,
                path,
                cancellationToken).ConfigureAwait(false));
        }

        return results;
    }

    private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsBySidoAsync(
        SbsElectionConfiguration configuration,
        IReadOnlyList<DistrictSelectionOption> districts,
        CancellationToken cancellationToken)
    {
        var sidoCodes = districts
            .Select(district => district.ParentRegionCode)
            .Where(code => !string.IsNullOrWhiteSpace(code))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(code => code, StringComparer.Ordinal)
            .ToArray();

        return await GetCountingItemsBySidoCodesAsync(
            configuration,
            sidoCodes,
            cancellationToken).ConfigureAwait(false);
    }

    private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsBySidoCodesAsync(
        SbsElectionConfiguration configuration,
        IReadOnlyList<string> sidoCodes,
        CancellationToken cancellationToken)
    {
        if (CanDeriveDistrictsFromCounting(configuration))
        {
            if (sidoCodes.Count == 0)
            {
                return await GetCountingItemsForPathAsync(
                    configuration,
                    BuildCountingPath(configuration, string.Empty),
                    cancellationToken).ConfigureAwait(false);
            }

            var basicResults = new List<SbsCountingResponseItem>();
            foreach (var sidoChunk in sidoCodes.Chunk(24))
            {
                var sidos = string.Join(",", sidoChunk.Select(Uri.EscapeDataString));
                var path = BuildCountingPath(configuration, $"sidos={sidos}");
                basicResults.AddRange(await GetCountingItemsForPathAsync(
                    configuration,
                    path,
                    cancellationToken).ConfigureAwait(false));
            }

            return basicResults;
        }

        var results = new List<SbsCountingResponseItem>();

        foreach (var sidoChunk in sidoCodes.Chunk(24))
        {
            var sidos = string.Join(",", sidoChunk.Select(Uri.EscapeDataString));
            var path = BuildCountingPath(configuration, $"sidos={sidos}");
            results.AddRange(await GetCountingItemsForPathAsync(
                configuration,
                path,
                cancellationToken).ConfigureAwait(false));
        }

        return results;
    }

    private static bool IsCountingItemInSido(
        SbsCountingItem item,
        ISet<string> sidoCodes)
    {
        var sidoCode = item.Region?.Name1Id;
        return !string.IsNullOrWhiteSpace(sidoCode) &&
               sidoCodes.Contains(sidoCode);
    }

    private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsForPathAsync(
        SbsElectionConfiguration configuration,
        string path,
        CancellationToken cancellationToken)
    {
        if (!ShouldCacheBasicCouncilCounting(configuration))
        {
            var uncachedItems = await GetArrayAsync<SbsCountingItem>(
                configuration.BaseUri,
                path,
                cancellationToken).ConfigureAwait(false);
            return uncachedItems.Select(item => new SbsCountingResponseItem(item, $"GET /{path}")).ToArray();
        }

        var cacheKey = $"{configuration.BaseUri.AbsoluteUri}|{path}";
        await _countingCacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            var now = DateTimeOffset.Now;
            if (_countingCache.TryGetValue(cacheKey, out var cached) &&
                now - cached.ReceivedAt < BasicCouncilCountingCacheDuration)
            {
                return cached.Items
                    .Select(item => new SbsCountingResponseItem(item, cached.SourcePath))
                    .ToArray();
            }

            var items = await GetArrayAsync<SbsCountingItem>(
                configuration.BaseUri,
                path,
                cancellationToken).ConfigureAwait(false);
            var responseItems = items.ToArray();
            _countingCache[cacheKey] = new SbsCountingCacheEntry(
                now,
                responseItems,
                $"GET /{path}");

            return responseItems.Select(item => new SbsCountingResponseItem(item, $"GET /{path}")).ToArray();
        }
        finally
        {
            _countingCacheLock.Release();
        }
    }

    private static SbsElectionRefreshResult CreateCountingRefreshResult(
        SbsElectionConfiguration configuration,
        DistrictSelectionOption district,
        SbsCountingItem item,
        string sourcePath)
    {
        var seatCount = Math.Max(0, item.Region?.SeatCount ?? 0);
        var countingClosed = item.GaepyoMagam;
        var candidates = (item.Hubojas ?? [])
            .Select(candidate => MapCandidate(candidate, seatCount, countingClosed))
            .OrderByDescending(candidate => candidate.VoteCount)
            .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
            .ToArray();
        var totalVotes = item.Total?.Tupyosu ?? candidates.Sum(candidate => candidate.VoteCount);
        var fallbackRegion = CreateRegionInfo(district);
        var regionName = ExpandRegionName(item.Region?.Name1 ?? fallbackRegion.Name1 ?? district.RegionName);
        var outputRegionName = BuildOutputRegionName(regionName);
        var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, fallbackRegion);
        var displayName = configuration.SungerType is 2 or 4 or 5 or 6
            ? BuildFullDistrictDisplayName(outputRegionName, districtLabel)
            : regionName;

        return new SbsElectionRefreshResult(
            DistrictName: displayName,
            DistrictCode: district.DistrictCode,
            RegionName: outputRegionName,
            ElectionDistrictName: districtLabel,
            TotalExpectedVotes: Math.Max(totalVotes, 1),
            TurnoutVotes: Math.Max(totalVotes, 0),
            CountedRate: item.Total?.GaepyoRate,
            CountedVotes: item.Total?.Gaepyosu,
            RemainingVotes: item.Total?.UncountedPyosu,
            SeatCount: seatCount,
            CountingClosed: countingClosed,
            Candidates: candidates,
            ReceivedAt: DateTimeOffset.Now,
            SourcePath: sourcePath);
    }

    private static CandidateEntry MapCandidate(SbsCandidateItem item, int seatCount, bool countingClosed)
    {
        var total = item.Total ?? new SbsCandidateVoteSnapshot();
        var judgement = MapJudgement(item.Degree);
        if (judgement == CandidateJudgement.None &&
            countingClosed &&
            seatCount > 0 &&
            total.Rank > 0 &&
            total.Rank <= seatCount)
        {
            judgement = CandidateJudgement.ElectedAfterCountComplete;
        }

        return new CandidateEntry
        {
            CandidateCode = string.IsNullOrWhiteSpace(item.Giho) ? (item.Name ?? "후보") : item.Giho,
            BallotNumber = item.Giho ?? string.Empty,
            Name = item.Name ?? "후보자명 미상",
            Party = item.Jeongdang?.Name ?? "무소속",
            VoteCount = total.Dugpyosu,
            VoteRate = total.DugpyoRate,
            HasImage = true,
            ManualJudgement = judgement,
            BroadcastRank = total.Rank,
            BroadcastSeatCount = seatCount,
            BroadcastCountingClosed = countingClosed
        };
    }

    private static CandidateJudgement MapJudgement(string? degree)
    {
        return NormalizeJudgementCode(degree) switch
        {
            "40" or "유력" => CandidateJudgement.Leading,
            "50" or "확정" or "확실" => CandidateJudgement.Confirmed,
            "60" or "개표중당선" => CandidateJudgement.ElectedInProgress,
            "70" or "당선" => CandidateJudgement.Elected,
            "80" or "무투표당선" => CandidateJudgement.UnopposedElected,
            "90" or "개표마감당선" => CandidateJudgement.ElectedAfterCountComplete,
            _ => CandidateJudgement.None
        };
    }

    private static string NormalizeJudgementCode(string? degree)
    {
        if (string.IsNullOrWhiteSpace(degree))
        {
            return string.Empty;
        }

        var normalized = degree.Trim();
        if (int.TryParse(normalized, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericCode))
        {
            return numericCode.ToString(CultureInfo.InvariantCulture);
        }

        return string.Concat(normalized.Where(character => !char.IsWhiteSpace(character)));
    }

    private static int ResolveJudgementDisplayPriority(CandidateJudgement judgement)
    {
        return judgement switch
        {
            CandidateJudgement.Elected or
                CandidateJudgement.ElectedInProgress or
                CandidateJudgement.UnopposedElected or
                CandidateJudgement.ElectedAfterCountComplete => 0,
            CandidateJudgement.Confirmed => 1,
            CandidateJudgement.Leading => 2,
            _ => int.MaxValue
        };
    }

    private static string BuildOverviewJudgementBadgeText(IReadOnlyList<CandidateEntry> candidates)
    {
        if (candidates.Count == 0)
        {
            return string.Empty;
        }

        return string.Join(
            " · ",
            candidates
                .GroupBy(candidate => candidate.EffectiveJudgement)
                .Select(group =>
                {
                    var label = ResolveOverviewJudgementLabel(group.Key);
                    return group.Count() == 1 ? label : $"{label} {group.Count()}";
                })
                .Where(label => !string.IsNullOrWhiteSpace(label)));
    }

    private static string BuildOverviewJudgementDetailText(IReadOnlyList<CandidateEntry> candidates)
    {
        if (candidates.Count == 0)
        {
            return string.Empty;
        }

        var names = candidates
            .Take(2)
            .Select(candidate => string.IsNullOrWhiteSpace(candidate.Party)
                ? candidate.Name
                : $"{candidate.Name}({candidate.Party})")
            .ToArray();

        return candidates.Count <= names.Length
            ? string.Join(", ", names)
            : $"{string.Join(", ", names)} 외 {candidates.Count - names.Length}";
    }

    private static string ResolveOverviewJudgementLabel(CandidateJudgement judgement)
    {
        return judgement switch
        {
            CandidateJudgement.Leading => "유력",
            CandidateJudgement.Confirmed => "확실",
            CandidateJudgement.Elected => "당선",
            CandidateJudgement.ElectedInProgress => "개표중 당선",
            CandidateJudgement.UnopposedElected => "무투표 당선",
            CandidateJudgement.ElectedAfterCountComplete => "개표마감 당선",
            _ => string.Empty
        };
    }

    private async Task<SbsRegionInfo> ResolveSidoRegionAsync(
        SbsElectionConfiguration configuration,
        string districtName,
        string districtCode,
        CancellationToken cancellationToken)
    {
        _sidoRegions ??= await GetValueAsync<SbsRegionInfo>(
            configuration.BaseUri,
            "sungerInfo/region?type=시도",
            cancellationToken).ConfigureAwait(false);

        if (!string.IsNullOrWhiteSpace(districtCode))
        {
            var matchedById = _sidoRegions.FirstOrDefault(region => string.Equals(region.Id, districtCode, StringComparison.OrdinalIgnoreCase));
            if (matchedById is not null)
            {
                return matchedById;
            }
        }

        var normalizedName = NormalizeRegionName(districtName);
        var matchedByName = _sidoRegions.FirstOrDefault(region =>
            string.Equals(NormalizeRegionName(region.Name), normalizedName, StringComparison.Ordinal) ||
            string.Equals(NormalizeRegionName(region.Name1), normalizedName, StringComparison.Ordinal));

        return matchedByName
            ?? throw new InvalidOperationException($"시도 정보를 찾지 못했습니다: '{districtName}'");
    }

    private async Task<SbsRegionInfo> ResolveElectionDistrictAsync(
        SbsElectionConfiguration configuration,
        string districtName,
        string districtCode,
        CancellationToken cancellationToken)
    {
        var regions = await GetElectionDistrictRegionsAsync(
            configuration,
            Array.Empty<string>(),
            cancellationToken).ConfigureAwait(false);

        if (!string.IsNullOrWhiteSpace(districtCode))
        {
            var matchedById = regions.FirstOrDefault(region => string.Equals(region.Id, districtCode, StringComparison.OrdinalIgnoreCase));
            if (matchedById is not null)
            {
                return matchedById;
            }
        }

        var normalizedName = NormalizeRegionName(districtName);
        if (!string.IsNullOrWhiteSpace(normalizedName))
        {
            var matchedByName = regions
                .Where(region => MatchesElectionDistrictName(region, normalizedName))
                .ToArray();

            if (matchedByName.Length == 1)
            {
                return matchedByName[0];
            }

            if (matchedByName.Length > 1 && !string.IsNullOrWhiteSpace(districtCode))
            {
                var narrowed = matchedByName
                    .Where(region =>
                        string.Equals(region.Name1Id, districtCode, StringComparison.OrdinalIgnoreCase) ||
                        string.Equals(region.Name2Id, districtCode, StringComparison.OrdinalIgnoreCase))
                    .ToArray();

                if (narrowed.Length == 1)
                {
                    return narrowed[0];
                }
            }
        }

        if (!string.IsNullOrWhiteSpace(districtCode))
        {
            var matchedBySecondaryCode = regions
                .Where(region => string.Equals(region.Name2Id, districtCode, StringComparison.OrdinalIgnoreCase))
                .ToArray();

            if (matchedBySecondaryCode.Length == 1)
            {
                return matchedBySecondaryCode[0];
            }
        }

        throw new InvalidOperationException($"선거구 정보를 찾지 못했습니다: '{districtName}'");
    }

    private async Task<IReadOnlyList<SbsRegionInfo>> GetElectionDistrictRegionsAsync(
        SbsElectionConfiguration configuration,
        IReadOnlyList<string> scopedSidoCodes,
        CancellationToken cancellationToken)
    {
        var scopedKey = scopedSidoCodes.Count == 0
            ? "all"
            : string.Join(",", scopedSidoCodes.OrderBy(code => code, StringComparer.Ordinal));
        var cacheKey = $"{configuration.BaseUri.AbsoluteUri}|{configuration.SungerType}|{scopedKey}";
        if (!_districtRegions.TryGetValue(cacheKey, out var regions))
        {
            if (CanDeriveDistrictsFromCounting(configuration))
            {
                regions = scopedSidoCodes.Count == 0
                    ? Array.Empty<SbsRegionInfo>()
                    : await GetCountingDistrictRegionsAsync(configuration, scopedSidoCodes, cancellationToken).ConfigureAwait(false);
            }
            else
            {
                regions = await GetValueAsync<SbsRegionInfo>(
                        configuration.BaseUri,
                        $"sungerInfo/region?type=선거구&sungerType={configuration.SungerType}",
                        cancellationToken)
                    .ConfigureAwait(false);
            }

            if (scopedSidoCodes.Count > 0)
            {
                var scopedSidoCodeSet = scopedSidoCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
                regions = regions
                    .Where(region => string.IsNullOrWhiteSpace(region.Name1Id) || scopedSidoCodeSet.Contains(region.Name1Id))
                    .ToArray();
            }

            _districtRegions[cacheKey] = regions;
        }

        return regions;
    }

    private async Task<IReadOnlyList<SbsRegionInfo>> GetCountingDistrictRegionsAsync(
        SbsElectionConfiguration configuration,
        IReadOnlyList<string> scopedSidoCodes,
        CancellationToken cancellationToken)
    {
        var items = await GetCountingItemsBySidoCodesAsync(
            configuration,
            scopedSidoCodes,
            cancellationToken).ConfigureAwait(false);

        return items
            .Select(item => CreateRegionInfo(item.Item.Region))
            .Where(region => region is not null && !string.IsNullOrWhiteSpace(region.Id))   
            .Select(region => region!)
            .GroupBy(region => region.Id, StringComparer.OrdinalIgnoreCase)
            .Select(group => group.First())
            .OrderBy(region => region.Order > 0 ? region.Order : int.MaxValue)
            .ThenBy(region => region.Id, StringComparer.Ordinal)
            .ToArray();
    }

    private async Task<IReadOnlyList<T>> GetValueAsync<T>(Uri baseUri, string relativePath, CancellationToken cancellationToken)
    {
        var json = await GetJsonAsync(baseUri, relativePath, cancellationToken).ConfigureAwait(false);
        return DeserializeList<T>(json, relativePath, preferValueProperty: true);
    }

    private async Task<IReadOnlyList<T>> GetArrayAsync<T>(Uri baseUri, string relativePath, CancellationToken cancellationToken)
    {
        var json = await GetJsonAsync(baseUri, relativePath, cancellationToken).ConfigureAwait(false);
        return DeserializeList<T>(json, relativePath, preferValueProperty: false);
    }

    private async Task<string> GetJsonAsync(Uri baseUri, string relativePath, CancellationToken cancellationToken)
    {
        var requestUri = new Uri(baseUri, relativePath);
        var cacheKey = requestUri.AbsoluteUri;
        var now = DateTimeOffset.Now;
        await _jsonCacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (_jsonCache.TryGetValue(cacheKey, out var cached) &&
                now - cached.ReceivedAt < ApiResponseCacheDuration)
            {
                return cached.Body;
            }
        }
        finally
        {
            _jsonCacheLock.Release();
        }

        using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
        var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
        var body = Encoding.UTF8.GetString(bytes);

        if (!response.IsSuccessStatusCode)
        {
            var detail = CreateResponseDetail(body);
            throw new HttpRequestException(
                $"SBS API 요청 실패: GET {requestUri.PathAndQuery} -> {(int)response.StatusCode} {response.ReasonPhrase}{detail}");
        }

        await _jsonCacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            _jsonCache[cacheKey] = new JsonCacheEntry(DateTimeOffset.Now, body);
        }
        finally
        {
            _jsonCacheLock.Release();
        }

        return body;
    }

    private static string BuildCountingPath(SbsElectionConfiguration configuration, string query)
        => string.IsNullOrWhiteSpace(query)
            ? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus"
            : $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus?{query}";

    private static bool CanDeriveDistrictsFromCounting(SbsElectionConfiguration configuration)
        => Uri.Compare(
               configuration.BaseUri,
               BasicApiBaseUri,
               UriComponents.SchemeAndServer | UriComponents.Path,
               UriFormat.SafeUnescaped,
               StringComparison.OrdinalIgnoreCase) == 0 &&
           IsBasicCouncilCountingType(configuration.SungerType);

    private static bool CanQueryCountingBySido(
        SbsElectionConfiguration configuration,
        IReadOnlyList<DistrictSelectionOption> districts)
        => IsBasicCouncilCountingType(configuration.SungerType) &&
           CanDeriveDistrictsFromCounting(configuration) &&
           districts.Count > 0 &&
           districts.All(district => !string.IsNullOrWhiteSpace(district.ParentRegionCode));

    private static bool CanQueryBasicCouncilByDistrictId(SbsElectionConfiguration configuration)
        => IsBasicCouncilCountingType(configuration.SungerType) && CanDeriveDistrictsFromCounting(configuration);

    private static bool ShouldCacheBasicCouncilCounting(SbsElectionConfiguration configuration)
        => IsBasicCouncilCountingType(configuration.SungerType) && CanDeriveDistrictsFromCounting(configuration);

    private static bool IsBasicCouncilCountingType(int sungerType)
        => sungerType is 5 or 6;

    public static IReadOnlyList<string> ResolveBasicApiSidoCodes(IEnumerable<string> regionNames)
    {
        return (regionNames ?? Array.Empty<string>())
            .Select(ResolveBasicApiSidoCode)
            .Where(code => !string.IsNullOrWhiteSpace(code))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(code => code, StringComparer.Ordinal)
            .ToArray();
    }

    public static string ResolveBasicApiSidoCode(string? regionName)
    {
        if (string.IsNullOrWhiteSpace(regionName))
        {
            return string.Empty;
        }

        var normalized = NormalizeRegionName(regionName);
        return BasicApiSidoCodes.TryGetValue(normalized, out var code) ||
            BasicApiSidoCodes.TryGetValue(regionName.Trim(), out code)
            ? code
            : string.Empty;
    }

    private static Uri ResolveBasicApiBaseUri()
    {
        var configuredBaseUrl = Environment.GetEnvironmentVariable(BasicApiBaseUrlEnvironmentVariable);
        if (!string.IsNullOrWhiteSpace(configuredBaseUrl) &&
            Uri.TryCreate(EnsureTrailingSlash(configuredBaseUrl.Trim()), UriKind.Absolute, out var configuredUri))
        {
            return configuredUri;
        }

        return new Uri("http://210.180.17.148/sbs-basic-api/");
    }

    private static string ResolveBasicApiCountingEndpointSegment()
    {
        var mode = Environment.GetEnvironmentVariable(BasicApiModeEnvironmentVariable)?.Trim().ToLowerInvariant();
        return mode switch
        {
            "dev" or "development" or "test" or "stage" or "staging" or "개발" => "gaepyo-dev",
            "prod" or "production" or "real" or "operating" or "운영" => "gaepyo",
            _ => "gaepyo"
        };
    }

    private static string EnsureTrailingSlash(string value)
        => value.EndsWith("/", StringComparison.Ordinal) ? value : $"{value}/";

    private static string CreateResponseDetail(string body)
    {
        var trimmed = body.Replace("\r", " ", StringComparison.Ordinal)
            .Replace("\n", " ", StringComparison.Ordinal)
            .Trim();

        if (string.IsNullOrWhiteSpace(trimmed))
        {
            return string.Empty;
        }

        return $" / {trimmed[..Math.Min(trimmed.Length, 180)]}";
    }

    private static IReadOnlyList<T> DeserializeList<T>(string json, string relativePath, bool preferValueProperty)
    {
        using var document = JsonDocument.Parse(json);
        var root = document.RootElement;

        JsonElement arrayElement;
        if (preferValueProperty &&
            root.ValueKind == JsonValueKind.Object &&
            root.TryGetProperty("value", out var valueElement) &&
            valueElement.ValueKind == JsonValueKind.Array)
        {
            arrayElement = valueElement;
        }
        else if (root.ValueKind == JsonValueKind.Array)
        {
            arrayElement = root;
        }
        else if (root.ValueKind == JsonValueKind.Object &&
                 root.TryGetProperty("value", out valueElement) &&
                 valueElement.ValueKind == JsonValueKind.Array)
        {
            arrayElement = valueElement;
        }
        else
        {
            throw new InvalidOperationException($"SBS API 배열 응답을 확인하지 못했습니다: {relativePath}");
        }

        var result = JsonSerializer.Deserialize<List<T>>(arrayElement.GetRawText(), SerializerOptions);
        return result ?? [];
    }

    private static bool MatchesElectionDistrictName(SbsRegionInfo region, string normalizedName)
    {
        if (string.IsNullOrWhiteSpace(normalizedName))
        {
            return false;
        }

        return string.Equals(NormalizeRegionName(region.Name), normalizedName, StringComparison.Ordinal) ||
            string.Equals(NormalizeRegionName(region.Name1), normalizedName, StringComparison.Ordinal) ||
            string.Equals(NormalizeRegionName(region.Name2), normalizedName, StringComparison.Ordinal) ||
            string.Equals(NormalizeRegionName(region.Name4), normalizedName, StringComparison.Ordinal) ||
            string.Equals(NormalizeRegionName(BuildElectionDistrictLabel(region)), normalizedName, StringComparison.Ordinal) ||
            string.Equals(NormalizeRegionName(BuildFullDistrictDisplayName(region)), normalizedName, StringComparison.Ordinal);
    }

    private static string BuildElectionDistrictLabel(int sungerType, string regionName, SbsTurnoutRegion? region, SbsRegionInfo fallback)
    {
        return sungerType switch
        {
            3 => BuildMayorGovernorLabel(regionName, region?.Name4 ?? fallback.Name4),
            11 => BuildEducationOfficeLabel(regionName, region?.Name4 ?? fallback.Name4),
            2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region, fallback),
            _ => regionName
        };
    }

    private static string BuildElectionDistrictLabel(SbsRegionInfo region)
    {
        return BuildElectionDistrictLabel(region.Name4, region.Name2 ?? region.Name);
    }

    private static string BuildElectionDistrictLabel(SbsTurnoutRegion? region, SbsRegionInfo fallback)
    {
        if (region is not null)
        {
            var resolved = BuildElectionDistrictLabel(region.Name4, region.Name2 ?? region.Name);
            if (!string.IsNullOrWhiteSpace(resolved))
            {
                return resolved;
            }
        }

        return BuildElectionDistrictLabel(fallback);
    }

    private static string BuildElectionDistrictLabel(string? officeName, string? shortName)
    {
        var label = !string.IsNullOrWhiteSpace(officeName)
            ? officeName.Trim()
            : shortName?.Trim() ?? string.Empty;

        return NormalizeElectionDistrictDisplayLabel(label);
    }

    private static string BuildMayorGovernorLabel(string regionName, string? officeName)
    {
        if (!string.IsNullOrWhiteSpace(officeName))
        {
            return NormalizeMayorGovernorOfficeLabel(officeName);
        }

        var expandedRegionName = ExpandRegionName(regionName);
        if (string.IsNullOrWhiteSpace(expandedRegionName))
        {
            return regionName?.Trim() ?? string.Empty;
        }

        var shortRegionName = NormalizeRegionName(expandedRegionName);
        if (expandedRegionName.EndsWith("시", StringComparison.Ordinal))
        {
            return $"{shortRegionName}시장";
        }

        if (expandedRegionName.EndsWith("도", StringComparison.Ordinal))
        {
            return $"{shortRegionName}지사";
        }

        return expandedRegionName;
    }

    private static string NormalizeMayorGovernorOfficeLabel(string officeName)
    {
        var trimmed = officeName.Trim();
        var suffix = trimmed.EndsWith("시장", StringComparison.Ordinal)
            ? "시장"
            : trimmed.EndsWith("지사", StringComparison.Ordinal)
                ? "지사"
                : string.Empty;
        if (string.IsNullOrWhiteSpace(suffix))
        {
            return trimmed;
        }

        foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
        {
            if (trimmed.StartsWith(pair.Value, StringComparison.Ordinal))
            {
                return $"{pair.Key}{suffix}";
            }
        }

        var regionPart = trimmed[..^suffix.Length];
        var shortRegionName = NormalizeRegionName(regionPart);
        return string.IsNullOrWhiteSpace(shortRegionName)
            ? trimmed
            : $"{shortRegionName}{suffix}";
    }

    private static string BuildEducationOfficeLabel(string regionName, string? officeName)
    {
        if (!string.IsNullOrWhiteSpace(officeName))
        {
            return NormalizeEducationOfficeLabel(officeName);
        }

        var shortRegionName = NormalizeRegionName(regionName);
        return string.IsNullOrWhiteSpace(shortRegionName)
            ? regionName?.Trim() ?? string.Empty
            : $"{shortRegionName}교육감";
    }

    private static string NormalizeEducationOfficeLabel(string officeName)
    {
        var trimmed = officeName.Trim();
        const string suffix = "교육감";
        if (!trimmed.EndsWith(suffix, StringComparison.Ordinal))
        {
            return trimmed;
        }

        foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
        {
            if (trimmed.StartsWith(pair.Value, StringComparison.Ordinal))
            {
                return $"{pair.Key}{suffix}";
            }
        }

        var regionPart = trimmed[..^suffix.Length];
        var shortRegionName = NormalizeRegionName(regionPart);
        return string.IsNullOrWhiteSpace(shortRegionName)
            ? trimmed
            : $"{shortRegionName}{suffix}";
    }

    private static string BuildFullDistrictDisplayName(SbsRegionInfo region)
    {
        return BuildFullDistrictDisplayName(
            ExpandRegionName(region.Name1 ?? region.Name),
            BuildElectionDistrictLabel(region));
    }

    private static string BuildFullDistrictDisplayName(string regionName, string districtLabel)
    {
        if (string.IsNullOrWhiteSpace(regionName))
        {
            return districtLabel;
        }

        if (string.IsNullOrWhiteSpace(districtLabel) || string.Equals(regionName, districtLabel, StringComparison.Ordinal))
        {
            return regionName;
        }

        if (districtLabel.StartsWith(regionName, StringComparison.Ordinal))
        {
            return districtLabel;
        }

        return $"{regionName} {districtLabel}";
    }

    private static string NormalizeElectionDistrictDisplayLabel(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        var normalized = value.Trim();
        foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
        {
            normalized = normalized.Replace(pair.Value, pair.Key, StringComparison.Ordinal);
        }

        return normalized;
    }

    private static DistrictSelectionOption CreateDistrictSelectionOption(int sungerType, SbsRegionInfo region)
    {
        var regionName = ExpandRegionName(region.Name1 ?? region.Name);
        var outputRegionName = BuildOutputRegionName(regionName);
        var districtName = sungerType switch
        {
            3 => BuildMayorGovernorLabel(regionName, region.Name4),
            11 => BuildEducationOfficeLabel(regionName, region.Name4),
            2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region),
            _ => regionName
        };
        var displayRegionName = sungerType is 5 or 6 ? outputRegionName : regionName;
        var displayName = sungerType is 2 or 4 or 5 or 6
            ? BuildFullDistrictDisplayName(displayRegionName, districtName)
            : regionName;
        var turnoutRegionCode = sungerType switch
        {
            3 or 11 => region.Name1Id ?? region.Id,
            4 => region.Name2Id ?? string.Empty,
            _ => string.Empty
        };

        return new DistrictSelectionOption(
            DisplayName: displayName,
            DistrictCode: region.Id,
            RegionName: outputRegionName,
            DistrictName: districtName,
            ParentRegionCode: region.Name1Id ?? string.Empty,
            TurnoutRegionCode: turnoutRegionCode);
    }

    private static SbsRegionInfo CreateRegionInfo(DistrictSelectionOption option)
    {
        return new SbsRegionInfo
        {
            Id = option.DistrictCode,
            Name = option.DisplayName,
            Name1 = option.RegionName,
            Name2 = option.DistrictName,
            Name4 = option.DistrictName,
            Name1Id = option.ParentRegionCode
        };
    }

    private static SbsRegionInfo? CreateRegionInfo(SbsTurnoutRegion? region)
    {
        if (region is null)
        {
            return null;
        }

        var id = !string.IsNullOrWhiteSpace(region.Id)
            ? region.Id
            : region.Name4Id ?? string.Empty;

        if (string.IsNullOrWhiteSpace(id))
        {
            return null;
        }

        return new SbsRegionInfo
        {
            Id = id,
            Name = region.Name ?? region.Name4 ?? string.Empty,
            Name1 = region.Name1,
            Name2 = region.Name2,
            Name3 = region.Name3,
            Name4 = region.Name4,
            Name1Id = region.Name1Id,
            Name2Id = region.Name2Id,
            Name3Id = region.Name3Id,
            Name4Id = region.Name4Id,
            Order = region.Order
        };
    }

    private static string NormalizeRegionName(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        var normalized = value.Trim();
        foreach (var pair in FullRegionNames)
        {
            normalized = normalized.Replace(pair.Value, pair.Key, StringComparison.Ordinal);
        }

        return normalized
            .Replace("특별자치도", string.Empty, StringComparison.Ordinal)
            .Replace("특별자치시", string.Empty, StringComparison.Ordinal)
            .Replace("특별시", string.Empty, StringComparison.Ordinal)
            .Replace("광역시", string.Empty, StringComparison.Ordinal)
            .Replace("구청장", "구", StringComparison.Ordinal)
            .Replace("시장", "시", StringComparison.Ordinal)
            .Replace("군수", "군", StringComparison.Ordinal)
            .Replace("지사", string.Empty, StringComparison.Ordinal)
            .Replace("교육감", string.Empty, StringComparison.Ordinal)
            .Replace(" ", string.Empty, StringComparison.Ordinal)
            .Trim();
    }

    private static string BuildOutputRegionName(string? value)
    {
        var normalized = NormalizeRegionName(value);
        return string.IsNullOrWhiteSpace(normalized)
            ? value?.Trim() ?? string.Empty
            : normalized;
    }

    private static string ExpandRegionName(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        var normalized = NormalizeRegionName(value);
        return FullRegionNames.TryGetValue(normalized, out var fullName)
            ? fullName
            : value.Trim();
    }

    private readonly record struct SbsElectionConfiguration(
        int SungerType,
        bool SupportsPreElection,
        Uri BaseUri,
        string CountingEndpointSegment);

    private readonly record struct TurnoutQueryDefinition(
        int SungerType,
        string RegionSegment);

    private readonly record struct TurnoutTarget(
        string DisplayName,
        string DistrictCode,
        string RegionName,
        string ElectionDistrictName,
        string TurnoutRegionCode);

    public sealed record DistrictSelectionOption(
        string DisplayName,
        string DistrictCode,
        string RegionName,
        string DistrictName,
        string ParentRegionCode,
        string TurnoutRegionCode = "");

    public sealed record SbsElectionRefreshResult(
        string DistrictName,
        string DistrictCode,
        string RegionName,
        string ElectionDistrictName,
        int TotalExpectedVotes,
        int TurnoutVotes,
        double? CountedRate,
        int? CountedVotes,
        int? RemainingVotes,
        int SeatCount,
        bool CountingClosed,
        IReadOnlyList<CandidateEntry>? Candidates,
        DateTimeOffset ReceivedAt,
        string SourcePath,
        string ReferenceTimeLabel = "");

    public sealed record CountingOverviewItem(
        string DisplayName,
        string DistrictCode,
        double CountedRate,
        int CountedVotes,
        int TotalVotes,
        int UncountedVotes,
        string JudgementBadgeText = "",
        string JudgementDetailText = "");

    public sealed record TurnoutOverviewItem(
        string DisplayName,
        string RegionName,
        string DistrictName,
        string DistrictCode,
        int TotalExpectedVotes,
        int TurnoutVotes,
        double TurnoutRate,
        string ReferenceTimeLabel = "");

    public sealed record TurnoutOverviewResult(
        IReadOnlyList<TurnoutOverviewItem> Items,
        int TotalExpectedVotes,
        int TurnoutVotes,
        DateTimeOffset ReceivedAt,
        string ReferenceTimeLabel = "")
    {
        public double NationalTurnoutRate => TotalExpectedVotes <= 0
            ? 0
            : Math.Round(TurnoutVotes * 100d / TotalExpectedVotes, 1, MidpointRounding.AwayFromZero);
    }

    private sealed class SbsRegionInfo
    {
        [JsonPropertyName("id")]
        public string Id { get; set; } = string.Empty;

        [JsonPropertyName("name")]
        public string Name { get; set; } = string.Empty;

        [JsonPropertyName("name1")]
        public string? Name1 { get; set; }

        [JsonPropertyName("name2")]
        public string? Name2 { get; set; }

        [JsonPropertyName("name3")]
        public string? Name3 { get; set; }

        [JsonPropertyName("name4")]
        public string? Name4 { get; set; }

        [JsonPropertyName("name1Id")]
        public string? Name1Id { get; set; }

        [JsonPropertyName("name2Id")]
        public string? Name2Id { get; set; }

        [JsonPropertyName("name3Id")]
        public string? Name3Id { get; set; }

        [JsonPropertyName("name4Id")]
        public string? Name4Id { get; set; }

        [JsonPropertyName("order")]
        public int Order { get; set; }

        [JsonPropertyName("seatCount")]
        public int SeatCount { get; set; }
    }

    private sealed class SbsTurnoutItem
    {
        [JsonPropertyName("region")]
        public SbsTurnoutRegion? Region { get; set; }

        [JsonPropertyName("sungerinsu")]
        public int Sungerinsu { get; set; }

        [JsonPropertyName("lastReportTime")]
        public double? LastReportTime { get; set; }

        [JsonPropertyName("total")]
        public SbsTurnoutVoteSnapshot? Total { get; set; }
    }

    private sealed class SbsTurnoutRegion
    {
        [JsonPropertyName("id")]
        public string? Id { get; set; }

        [JsonPropertyName("name")]
        public string? Name { get; set; }

        [JsonPropertyName("name1")]
        public string? Name1 { get; set; }

        [JsonPropertyName("name2")]
        public string? Name2 { get; set; }

        [JsonPropertyName("name3")]
        public string? Name3 { get; set; }

        [JsonPropertyName("name4")]
        public string? Name4 { get; set; }

        [JsonPropertyName("name1Id")]
        public string? Name1Id { get; set; }

        [JsonPropertyName("name2Id")]
        public string? Name2Id { get; set; }

        [JsonPropertyName("name3Id")]
        public string? Name3Id { get; set; }

        [JsonPropertyName("name4Id")]
        public string? Name4Id { get; set; }

        [JsonPropertyName("order")]
        public int Order { get; set; }

        [JsonPropertyName("seatCount")]
        public int SeatCount { get; set; }
    }

    private sealed class SbsTurnoutVoteSnapshot
    {
        [JsonPropertyName("tupyosu")]
        public int Tupyosu { get; set; }
    }

    private sealed class SbsCountingItem
    {
        [JsonPropertyName("region")]
        public SbsTurnoutRegion? Region { get; set; }

        [JsonPropertyName("total")]
        public SbsCountingVoteSnapshot? Total { get; set; }

        [JsonPropertyName("hubojas")]
        public List<SbsCandidateItem>? Hubojas { get; set; }

        [JsonPropertyName("gaepyoMagam")]
        public bool GaepyoMagam { get; set; }
    }

    private readonly record struct SbsCountingResponseItem(
        SbsCountingItem Item,
        string SourcePath);

    private readonly record struct JsonCacheEntry(
        DateTimeOffset ReceivedAt,
        string Body);

    private readonly record struct SbsCountingCacheEntry(
        DateTimeOffset ReceivedAt,
        IReadOnlyList<SbsCountingItem> Items,
        string SourcePath);

    private sealed class SbsCountingVoteSnapshot
    {
        [JsonPropertyName("tupyosu")]
        public int Tupyosu { get; set; }

        [JsonPropertyName("gaepyosu")]
        public int Gaepyosu { get; set; }

        [JsonPropertyName("gaepyoRate")]
        public double GaepyoRate { get; set; }

        [JsonPropertyName("uncountedPyosu")]
        public int UncountedPyosu { get; set; }
    }

    private sealed class SbsCandidateItem
    {
        [JsonPropertyName("giho")]
        public string? Giho { get; set; }

        [JsonPropertyName("name")]
        public string? Name { get; set; }

        [JsonPropertyName("degree")]
        public string? Degree { get; set; }

        [JsonPropertyName("jeongdang")]
        public SbsPartyInfo? Jeongdang { get; set; }

        [JsonPropertyName("total")]
        public SbsCandidateVoteSnapshot? Total { get; set; }
    }

    private sealed class SbsPartyInfo
    {
        [JsonPropertyName("name")]
        public string? Name { get; set; }
    }

    private sealed class SbsCandidateVoteSnapshot
    {
        [JsonPropertyName("rank")]
        public int Rank { get; set; }

        [JsonPropertyName("dugpyosu")]
        public int Dugpyosu { get; set; }

        [JsonPropertyName("dugpyoRate")]
        public double DugpyoRate { get; set; }
    }
}
