using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using MMoneyCoderSharp.Data;

internal static class NamedPlaylistReadOnlyDbAudit
{
    private const string QueryName = "AUDIT_ACTIVE_NAMED_PLAYLIST_ROWS";
    private const int MaximumRows = 20_000;

    private const string Sql = """
        SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
        FROM (
            SELECT TRIM(d.DC_CODE) DC_CODE,
                   d.DC_TITLE DC_TITLE,
                   p.LIST_TEXT LIST_TEXT,
                   TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
            FROM DC_LIST d
            JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
            ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
        )
        WHERE ROWNUM <= :row_limit
        """;

    internal static async Task RunAsync(
        IDataQueryExecutor executor,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(executor);
        cancellationToken.ThrowIfCancellationRequested();

        Console.WriteLine("PLIST_READ_AUDIT: BEGIN");
        var rawRows = await LoadRawRowsAsync(executor, cancellationToken).ConfigureAwait(false);
        Console.WriteLine(
            $"PLIST_READ_AUDIT_RAW: rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)}");
        var persistence = new LegacyNamedPlaylistPersistenceService(executor);
        var definitions = await persistence
            .ListAsync(LegacyNamedPlaylistPersistenceService.MaximumDefinitions, cancellationToken)
            .ConfigureAwait(false);
        cancellationToken.ThrowIfCancellationRequested();
        if (definitions.IsTruncated || definitions.Items.Count == 0)
        {
            throw InvalidBoundary("definition cardinality");
        }
        Console.WriteLine(
            $"PLIST_READ_AUDIT_DEFINITIONS: count={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)}");

        var rawByProgram = rawRows
            .GroupBy(static row => row.ProgramCode, StringComparer.Ordinal)
            .ToDictionary(
                static group => group.Key,
                static group => (IReadOnlyList<LegacyPListAuditRow>)group.ToArray(),
                StringComparer.Ordinal);
        if (rawByProgram.Count != definitions.Items.Count ||
            rawRows.Count != rawByProgram.Values.Sum(static rows => rows.Count))
        {
            throw InvalidBoundary("active definition join");
        }

        var loadedRows = 0;
        foreach (var definition in definitions.Items)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (!rawByProgram.TryGetValue(definition.ProgramCode, out var programRows) ||
                !LegacyPListReadAuditEvidence.HasContiguousIndexes(programRows) ||
                programRows.Any(row =>
                    !string.Equals(row.ProgramTitle, definition.Title, StringComparison.Ordinal)))
            {
                throw InvalidBoundary("program identity or item order");
            }

            var loaded = await persistence
                .LoadAsync(definition.ProgramCode, cancellationToken)
                .ConfigureAwait(false);
            cancellationToken.ThrowIfCancellationRequested();
            if (loaded.Definition != definition || loaded.Items.Count != programRows.Count)
            {
                throw InvalidBoundary("typed load cardinality");
            }

            for (var index = 0; index < programRows.Count; index++)
            {
                if (!LegacyPListReadAuditEvidence.MatchesPersistenceResult(
                        programRows[index],
                        loaded.Items[index]))
                {
                    throw InvalidBoundary("lossless typed load comparison");
                }
            }

            loadedRows += loaded.Items.Count;
        }

        if (loadedRows != rawRows.Count)
        {
            throw InvalidBoundary("typed load total");
        }
        Console.WriteLine(
            $"PLIST_READ_AUDIT_TYPED: rows={loadedRows.ToString(CultureInfo.InvariantCulture)}");

        IReadOnlySet<SelectionKey> fixedSignatures;
        try
        {
            fixedSignatures = LoadFixedSignatures();
        }
        catch (InvalidDataException exception) when (
            exception.Message.StartsWith(
                "The read-only PList audit failed its ",
                StringComparison.Ordinal))
        {
            Console.WriteLine("PLIST_FIXED_MAPPER_BOUNDARY: " + exception.Message);
            throw;
        }
        Console.WriteLine(
            "PLIST_FIXED_MAPPER_AUDIT: " +
            $"sourceActions=328 unavailable=6 " +
            $"uniqueSignatures={fixedSignatures.Count.ToString(CultureInfo.InvariantCulture)} " +
            "sourceHashes=verified");
        var routedRows = rawRows
            .Select(row => new RoutedRow(row, ClassifyRoute(row, fixedSignatures)))
            .ToArray();
        var routeCounts = routedRows
            .GroupBy(static routed => routed.Route)
            .ToDictionary(static group => group.Key, static group => group.Count());
        MaterializationSummary outcomes;
        try
        {
            outcomes = await ResolveMaterializationAsync(
                    executor,
                    routedRows,
                    routeCounts,
                    cancellationToken)
                .ConfigureAwait(false);
        }
        catch (InvalidDataException exception) when (
            exception.Message.StartsWith(
                "The read-only PList audit failed its ",
                StringComparison.Ordinal))
        {
            Console.WriteLine("PLIST_MATERIALIZATION_BOUNDARY: " + exception.Message);
            throw;
        }
        if (outcomes.Playable + outcomes.Blocked != rawRows.Count)
        {
            throw InvalidBoundary("materialization total");
        }

        Console.WriteLine(
            "PLIST_READ_AUDIT: " +
            $"definitions={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
            $"rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
            $"parsed={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
            $"lossless={rawRows.Count(static row => row.IsLossless).ToString(CultureInfo.InvariantCulture)} " +
            "replacement=0 mojibake=0 nonContiguousPrograms=0 typedMismatch=0");
        Console.WriteLine(
            "PLIST_ROUTE_AUDIT: " + string.Join(
                ",",
                Enum.GetValues<LegacyPListAuditRoute>().Select(route =>
                    $"{route}={routeCounts.GetValueOrDefault(route).ToString(CultureInfo.InvariantCulture)}")));
        Console.WriteLine(
            "PLIST_MATERIALIZATION_AUDIT: " +
            $"unique={outcomes.Unique.ToString(CultureInfo.InvariantCulture)} " +
            $"selectionMapped={outcomes.Playable.ToString(CultureInfo.InvariantCulture)} " +
            $"restoreBlocked={outcomes.Blocked.ToString(CultureInfo.InvariantCulture)} " +
            "sceneDataAndAssetsNotAsserted=true");
        Console.WriteLine(
            "PLIST_BLOCK_REASONS: " +
            (outcomes.BlockReasons.Count == 0
                ? "none"
                : string.Join(
                    ",",
                    outcomes.BlockReasons
                        .OrderBy(static pair => pair.Key, StringComparer.Ordinal)
                        .Select(pair =>
                            $"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
    }

    private static async Task<IReadOnlyList<LegacyPListAuditRow>> LoadRawRowsAsync(
        IDataQueryExecutor executor,
        CancellationToken cancellationToken)
    {
        var spec = new DataQuerySpec(
            Sql,
            [new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
        spec.ValidateFor(DataSourceKind.Oracle);
        var table = await executor
            .ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken)
            .ConfigureAwait(false);
        cancellationToken.ThrowIfCancellationRequested();

        var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
        if (table is null || table.Columns.Count != columns.Length ||
            table.Rows.Count is < 1 or > MaximumRows)
        {
            throw InvalidBoundary("query schema or row bound");
        }

        for (var index = 0; index < columns.Length; index++)
        {
            if (!string.Equals(
                    table.Columns[index].ColumnName,
                    columns[index],
                    StringComparison.Ordinal) ||
                table.Columns[index].DataType != typeof(string))
            {
                throw InvalidBoundary("query schema");
            }
        }

        var rows = new List<LegacyPListAuditRow>(table.Rows.Count);
        foreach (DataRow dataRow in table.Rows)
        {
            var result = LegacyPListReadAuditParser.Parse(
                dataRow[0] as string,
                dataRow[1] as string,
                dataRow[3] as string,
                dataRow[2] as string);
            if (!result.IsSuccess)
            {
                // The failure enum contains no source text. Never echo raw
                // operator data or identifiers from this audit boundary.
                throw InvalidBoundary($"row parse ({result.Failure})");
            }

            rows.Add(result.Row!);
        }

        return rows.AsReadOnly();
    }

    private static async Task<MaterializationSummary> ResolveMaterializationAsync(
        IDataQueryExecutor executor,
        IReadOnlyList<RoutedRow> routedRows,
        IReadOnlyDictionary<LegacyPListAuditRoute, int> routeCounts,
        CancellationToken cancellationToken)
    {
        var summary = new MaterializationSummary();

        // These closed classifiers create one immutable selection candidate.
        // This audit does not call the scene data loaders or assert asset and
        // PREPARE readiness. Industry fixed rows are independently constrained
        // to two markets and three actions by LegacyPListReadAuditEvidence.
        summary.AddPlayable(routeCounts.GetValueOrDefault(
            LegacyPListAuditRoute.ExistingClosedMapper));
        summary.AddPlayable(routeCounts.GetValueOrDefault(
            LegacyPListAuditRoute.IndustryFixedClosedMapper));

        var comparisonRows = RowsFor(
            routedRows,
            LegacyPListAuditRoute.ComparisonFreshIdentity,
            static (row, index) => LegacyPListReadAuditEvidence
                .ToComparisonRow(row) with { ItemIndex = index });
        if (comparisonRows.Count > 0)
        {
            var resolved = await new LegacyNamedComparisonRestoreService(executor)
                .ResolveAsync(comparisonRows, cancellationToken)
                .ConfigureAwait(false);
            foreach (var row in resolved.Rows)
            {
                if (row.IsResolved)
                {
                    summary.AddPlayable(1);
                }
                else
                {
                    summary.AddBlocked("COMPARISON_" + row.Failure!.Value.ToString().ToUpperInvariant());
                }
            }
        }

        var foreignIndexRows = RowsFor(
            routedRows,
            LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity,
            static (row, index) => LegacyPListReadAuditEvidence
                .ToForeignIndexCandleRow(row) with { ItemIndex = index });
        if (foreignIndexRows.Count > 0)
        {
            var resolved = await new LegacyForeignIndexCandleRestoreService(executor)
                .ResolveAsync(foreignIndexRows, cancellationToken)
                .ConfigureAwait(false);
            foreach (var row in resolved.Rows)
            {
                if (row.IsResolved)
                {
                    summary.AddPlayable(1);
                }
                else
                {
                    summary.AddBlocked("FOREIGN_INDEX_" + row.Failure!.Value.ToString().ToUpperInvariant());
                }
            }
        }

        var nxtRows = RowsFor(
            routedRows,
            LegacyPListAuditRoute.NxtThemeFreshIdentity,
            static (row, index) => LegacyPListReadAuditEvidence
                .ToNxtThemeRow(row) with { ItemIndex = index });
        if (nxtRows.Count > 0)
        {
            var resolved = await new LegacyNamedNxtThemeRestoreService(executor)
                .ResolveAsync(nxtRows, cancellationToken)
                .ConfigureAwait(false);
            foreach (var row in resolved.Rows)
            {
                if (row.IsResolved)
                {
                    summary.AddPlayable(1);
                }
                else
                {
                    summary.AddBlocked("NXT_THEME_" + row.Failure!.Value.ToString().ToUpperInvariant());
                }
            }
        }

        await ResolveKrxThemesAsync(executor, routedRows, summary, cancellationToken)
            .ConfigureAwait(false);
        await ResolveForeignStocksAsync(executor, routedRows, summary, cancellationToken)
            .ConfigureAwait(false);

        // GraphE/FSell/VI require a fresh row-version or opened-handle trusted
        // file read. The DB-only audit intentionally does not claim those
        // external proofs and keeps every such row blocked with one reason.
        summary.AddBlocked(
            "MANUAL_TRUSTED_SOURCE_FRESH_READ_REQUIRED",
            routeCounts.GetValueOrDefault(LegacyPListAuditRoute.ManualFreshMaterialization));
        summary.AddBlocked(
            "NO_UNIQUE_CLOSED_MAPPER",
            routeCounts.GetValueOrDefault(LegacyPListAuditRoute.Unmapped));

        return summary;
    }

    private static async Task ResolveKrxThemesAsync(
        IDataQueryExecutor executor,
        IReadOnlyList<RoutedRow> rows,
        MaterializationSummary summary,
        CancellationToken cancellationToken)
    {
        var candidates = RowsFor(
            rows,
            LegacyPListAuditRoute.KrxThemeFreshIdentity,
            static (row, index) => new LegacyNamedKrxThemeRestoreRow(
                index,
                row.IsEnabled,
                row.GroupCode,
                row.Subject,
                row.GraphicType,
                row.Subtype,
                row.PageTextForDisplay,
                row.DataCode));
        if (candidates.Count == 0)
        {
            return;
        }

        var resolved = await new LegacyNamedKrxThemeRestoreService(executor)
            .ResolveAsync(candidates, cancellationToken)
            .ConfigureAwait(false);
        var mapped = 0;
        var remapped = 0;
        foreach (var row in resolved.Rows)
        {
            if (row.IsResolved)
            {
                summary.AddPlayable(1);
                mapped++;
                if (row.Identity!.CodeWasRemapped)
                {
                    remapped++;
                }
            }
            else
            {
                summary.AddBlocked("KRX_THEME_" + KrxThemeFailureToken(row.Failure!.Value));
            }
        }

        Console.WriteLine(
            "PLIST_KRX_THEME_RESTORE: " +
            $"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
            $"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
            $"codeRemapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
            $"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)}");
    }

    private static async Task ResolveForeignStocksAsync(
        IDataQueryExecutor executor,
        IReadOnlyList<RoutedRow> rows,
        MaterializationSummary summary,
        CancellationToken cancellationToken)
    {
        var candidates = RowsFor(
            rows,
            LegacyPListAuditRoute.ForeignStockFreshIdentity,
            static (row, index) => new LegacyNamedForeignStockRestoreRow(
                index,
                row.IsEnabled,
                row.GroupCode,
                row.Subject,
                row.GraphicType,
                row.Subtype,
                row.PageTextForDisplay,
                row.DataCode));
        if (candidates.Count == 0)
        {
            return;
        }

        var resolved = await new LegacyNamedForeignStockRestoreService(executor)
            .ResolveAsync(candidates, cancellationToken)
            .ConfigureAwait(false);
        var mapped = 0;
        foreach (var row in resolved.Rows)
        {
            if (row.IsResolved)
            {
                summary.AddPlayable(1);
                mapped++;
            }
            else
            {
                summary.AddBlocked(
                    "FOREIGN_STOCK_" + ForeignStockFailureToken(row.Failure!.Value));
            }
        }

        var actions = resolved.Rows
            .Where(static row => row.IsResolved)
            .GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
            .OrderBy(static group => group.Key, StringComparer.Ordinal)
            .Select(group =>
                $"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
        var databaseIssues = resolved.Rows
            .Where(static row =>
                row.Failure == LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid)
            .GroupBy(static row => row.DatabaseIssue?.ToString() ?? "UNCLASSIFIED", StringComparer.Ordinal)
            .OrderBy(static group => group.Key, StringComparer.Ordinal)
            .Select(group =>
                $"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
        var slashSymbols = resolved.Rows.Count(static row =>
            row.IsResolved && row.Identity!.Symbol.Contains('/'));
        var nonAsciiSymbols = resolved.Rows.Count(static row =>
            row.IsResolved && row.Identity!.Symbol.Any(static character => !char.IsAscii(character)));
        var hangulSymbols = resolved.Rows.Count(static row =>
            row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit));
        var internalSpaceSymbols = resolved.Rows.Count(static row =>
            row.IsResolved && row.Identity!.Symbol.Contains(' '));
        var hangulActions = resolved.Rows
            .Where(static row =>
                row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit))
            .GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
            .OrderBy(static group => group.Key, StringComparer.Ordinal)
            .Select(group =>
                $"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
        Console.WriteLine(
            "PLIST_FOREIGN_STOCK_RESTORE: " +
            $"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
            $"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
            $"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)} " +
            $"slashSymbols={slashSymbols.ToString(CultureInfo.InvariantCulture)} " +
            $"nonAsciiSymbols={nonAsciiSymbols.ToString(CultureInfo.InvariantCulture)} " +
            $"hangulSymbols={hangulSymbols.ToString(CultureInfo.InvariantCulture)} " +
            $"internalSpaceSymbols={internalSpaceSymbols.ToString(CultureInfo.InvariantCulture)} " +
            $"actions={string.Join(',', actions)} " +
            $"hangulActions={string.Join(',', hangulActions)} " +
            $"databaseIssues={(databaseIssues.Any() ? string.Join(',', databaseIssues) : "none")} " +
            "rawValuesPrinted=false");
    }

    private static string KrxThemeFailureToken(LegacyNamedKrxThemeRestoreFailure failure) =>
        failure switch
        {
            LegacyNamedKrxThemeRestoreFailure.InvalidRow => "INVALID_ROW",
            LegacyNamedKrxThemeRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION",
            LegacyNamedKrxThemeRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
            LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
            LegacyNamedKrxThemeRestoreFailure.PreviewEmpty => "PREVIEW_EMPTY",
            LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
            _ => throw new ArgumentOutOfRangeException(nameof(failure))
        };

    private static string ForeignStockFailureToken(
        LegacyNamedForeignStockRestoreFailure failure) =>
        failure switch
        {
            LegacyNamedForeignStockRestoreFailure.InvalidRow => "INVALID_ROW",
            LegacyNamedForeignStockRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
            LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
            LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
            _ => throw new ArgumentOutOfRangeException(nameof(failure))
        };

    private static IReadOnlyList<T> RowsFor<T>(
        IReadOnlyList<RoutedRow> rows,
        LegacyPListAuditRoute route,
        Func<LegacyPListAuditRow, int, T> convert) =>
        rows
            .Where(row => row.Route == route)
            .Select(static row => row.Row)
            .Select(convert)
            .ToArray();

    private static bool HasExactStringColumn(DataColumn column, string name) =>
        string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
        column.DataType == typeof(string);

    private static bool IsHangulCodeUnit(char value) =>
        value is >= '\uAC00' and <= '\uD7AF' or
            >= '\u1100' and <= '\u11FF' or
            >= '\u3130' and <= '\u318F';

    private static InvalidDataException InvalidBoundary(string detail) =>
        new($"The read-only PList audit failed its {detail} boundary. No source values were printed.");

    private static LegacyPListAuditRoute ClassifyRoute(
        LegacyPListAuditRow row,
        IReadOnlySet<SelectionKey> fixedSignatures)
    {
        var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
        if (route != LegacyPListAuditRoute.Unmapped)
        {
            return route;
        }

        var key = new SelectionKey(
            row.GroupCode,
            row.Subject,
            row.GraphicType,
            row.Subtype,
            row.DataCode);
        return fixedSignatures.Contains(key)
            ? LegacyPListAuditRoute.ExistingClosedMapper
            : LegacyPListAuditRoute.Unmapped;
    }

    private static IReadOnlySet<SelectionKey> LoadFixedSignatures()
    {
        var profile = Environment.GetFolderPath(
            Environment.SpecialFolder.UserProfile,
            Environment.SpecialFolderOption.DoNotVerify);
        if (string.IsNullOrWhiteSpace(profile))
        {
            throw InvalidBoundary("legacy fixed-profile root");
        }

        var directory = Path.GetFullPath(Path.Combine(
            profile,
            "source",
            "repos",
            "MBN_STOCK_N",
            "MBN_STOCK_N",
            "bin",
            "Debug",
            "Res"));
        if (!Directory.Exists(directory) ||
            (File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
        {
            throw InvalidBoundary("legacy fixed source directory");
        }

        Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
        var encoding = Encoding.GetEncoding(
            949,
            EncoderFallback.ExceptionFallback,
            DecoderFallback.ExceptionFallback);
        var signatureCounts = new Dictionary<SelectionKey, int>();
        var actionCount = 0;
        foreach (var source in FixedSources)
        {
            var path = Path.GetFullPath(Path.Combine(directory, source.FileName));
            if (!string.Equals(
                    Path.GetDirectoryName(path),
                    directory,
                    StringComparison.OrdinalIgnoreCase) ||
                !File.Exists(path) ||
                (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
            {
                throw InvalidBoundary("legacy fixed source file");
            }

            byte[] bytes;
            using (var stream = new FileStream(
                path,
                FileMode.Open,
                FileAccess.Read,
                FileShare.Read))
            {
                if (stream.Length is < 1 or > 32_768)
                {
                    throw InvalidBoundary("legacy fixed source size");
                }

                bytes = new byte[stream.Length];
                stream.ReadExactly(bytes);
            }

            if (!string.Equals(
                    Convert.ToHexString(SHA256.HashData(bytes)),
                    source.ExpectedSha256,
                    StringComparison.Ordinal))
            {
                throw InvalidBoundary("legacy fixed source hash");
            }

            string text;
            try
            {
                text = encoding.GetString(bytes);
            }
            catch (DecoderFallbackException)
            {
                throw InvalidBoundary("legacy fixed source encoding");
            }

            AppendFixedSignatures(signatureCounts, source.Market, text, ref actionCount);
        }

        // The three audited runtime INI files contain 328 source actions. Six
        // unavailable manual placeholders are deliberately excluded below.
        if (actionCount != 322)
        {
            throw InvalidBoundary("legacy fixed signature cardinality");
        }

        // A raw row must identify exactly one current action. If two source
        // actions collapse to one historical five-field signature, retain no
        // signature for that key and let the row fail closed as unmapped.
        return signatureCounts
            .Where(static pair => pair.Value == 1)
            .Select(static pair => pair.Key)
            .ToHashSet();
    }

    private static void AppendFixedSignatures(
        IDictionary<SelectionKey, int> signatureCounts,
        string market,
        string text,
        ref int actionCount)
    {
        string? section = null;
        foreach (var rawLine in text.Split(["\r\n"], StringSplitOptions.None))
        {
            // MainForm's runtime INI loader calls Trim() on every line before
            // it builds the tree and playlist signature.
            var line = rawLine.Trim();
            if (line.Length == 0)
            {
                continue;
            }

            if (line.Length >= 3 && line[0] == '[' && line[^1] == ']')
            {
                section = line[1..^1];
                if (section.Length == 0 || LegacyPListReadAuditParser.ContainsMojibakeSignal(section))
                {
                    throw InvalidBoundary("legacy fixed section");
                }

                continue;
            }

            if (section is null || line.Contains('^') ||
                LegacyPListReadAuditParser.ContainsMojibakeSignal(line))
            {
                throw InvalidBoundary("legacy fixed action");
            }

            if (line == "VI 발동(수동)" ||
                section == "매매동향" && line is
                    ("개인 순매도 상위(수동)" or
                     "외국인 순매도 상위(수동)" or
                     "기관 순매도 상위(수동)"))
            {
                continue;
            }

            actionCount = checked(actionCount + 1);

            var separator = line.IndexOf('_');
            var first = separator < 0 ? line : line[..separator];
            var remainder = separator < 0 ? string.Empty : line[(separator + 1)..];
            SelectionKey signature;
            if (market is "overseas" or "exchange")
            {
                signature = new SelectionKey(
                    market == "overseas" ? "해외지수" : "환율",
                    first,
                    section,
                    remainder,
                    string.Empty);
            }
            else if (section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
            {
                signature = new SelectionKey(
                    FixedIndexGroup(line),
                    "지수",
                    section,
                    line,
                    string.Empty);
            }
            else if (section == "매매동향")
            {
                signature = new SelectionKey(
                    FixedIndexGroup(line),
                    "지수",
                    first,
                    remainder,
                    string.Empty);
            }
            else
            {
                signature = new SelectionKey(
                    section,
                    "지수",
                    first,
                    remainder,
                    string.Empty);
            }

            signatureCounts.TryGetValue(signature, out var existingCount);
            signatureCounts[signature] = checked(existingCount + 1);
        }
    }

    private static string FixedIndexGroup(string label)
    {
        if (label.Contains("코스피_NXT", StringComparison.Ordinal)) return "코스피_NXT";
        if (label.Contains("코스닥_NXT", StringComparison.Ordinal)) return "코스닥_NXT";
        if (label.Contains("코스피", StringComparison.Ordinal)) return "코스피";
        if (label.Contains("코스닥", StringComparison.Ordinal)) return "코스닥";
        return "전체";
    }

    private sealed class MaterializationSummary
    {
        public int Unique { get; private set; }

        public int Playable { get; private set; }

        public int Blocked { get; private set; }

        public Dictionary<string, int> BlockReasons { get; } = new(StringComparer.Ordinal);

        public void AddPlayable(int count)
        {
            if (count < 0)
            {
                throw new ArgumentOutOfRangeException(nameof(count));
            }

            Unique = checked(Unique + count);
            Playable = checked(Playable + count);
        }

        public void AddBlocked(string reason, int count = 1)
        {
            if (string.IsNullOrWhiteSpace(reason) || count < 0)
            {
                throw new ArgumentOutOfRangeException(nameof(count));
            }

            Unique = checked(Unique + count);
            Blocked = checked(Blocked + count);
            BlockReasons[reason] = checked(BlockReasons.GetValueOrDefault(reason) + count);
        }
    }

    private readonly record struct RoutedRow(
        LegacyPListAuditRow Row,
        LegacyPListAuditRoute Route);

    private readonly record struct SelectionKey(
        string GroupCode,
        string Subject,
        string GraphicType,
        string Subtype,
        string DataCode);

    private sealed record FixedSource(
        string FileName,
        string Market,
        string ExpectedSha256);

    private static readonly IReadOnlyList<FixedSource> FixedSources =
    [
        new(
            "해외.ini",
            "overseas",
            "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
        new(
            "환율.ini",
            "exchange",
            "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
        new(
            "지수.ini",
            "index",
            "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
    ];
}
