using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using RolemasterDb.App.Domain;
using SharedParsing = RolemasterDb.CriticalParsing;

namespace RolemasterDb.ImportTool.Parsing;

internal static class CriticalTableParserSupport
{
    internal const int HeaderToBodyMinimumGap = 20;
    internal const int HeaderToRowLabelMinimumGap = 10;
    internal const int FooterLabelExclusionGap = 15;
    internal const int FooterPageNumberExclusionGap = 80;
    internal const int RowLabelDuplicateTolerance = 15;
    internal const int TopGroupingTolerance = 2;
    internal const int BoundarySplitSearchRadiusChars = 12;

    private static readonly Regex MultiFragmentSplitRegex = new(@"\S(?:.*?\S)?(?=(?:\s{2,}|$))", RegexOptions.Compiled);
    private static readonly Regex NumericAffixLineRegex = new(@"^\d+(?:H|∑|∏|π|∫|\s*[–-])", RegexOptions.Compiled);
    private static readonly Regex StandaloneModifierAffixLineRegex = new(@"^(?:\d+)?\((?:\+|-|–)\d+\)$", RegexOptions.Compiled);
    private static readonly Regex BoundaryBonusLineRegex = new(@"^(?:all allies|all foe's allies|all foes|all opponents)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
    private static readonly Regex RollBandLabelRegex = new(@"^\d{2,3}(?:-\d{2,3})?$|^\d{2,3}\+$", RegexOptions.Compiled);

    // Left-edge OCR occasionally drops the first digit of the lower bound on standard-table row labels.
    private static readonly IReadOnlyDictionary<string, string> OcrDamagedStandardRollBandLabels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
    {
        ["1-05"] = "01-05",
        ["6-10"] = "06-10",
        ["1-15"] = "11-15",
        ["6-20"] = "16-20",
        ["1-35"] = "21-35",
        ["6-45"] = "36-45",
        ["6-50"] = "46-50",
        ["1-55"] = "51-55",
        ["6-60"] = "56-60",
        ["1-65"] = "61-65",
        ["7-70"] = "67-70",
        ["1-75"] = "71-75",
        ["6-80"] = "76-80",
        ["1-85"] = "81-85",
        ["6-90"] = "86-90",
        ["1-95"] = "91-95"
    };

    internal static List<PositionedTextFragment> LoadFragments(string xmlContent)
    {
        using var stringReader = new StringReader(xmlContent);
        using var xmlReader = XmlReader.Create(stringReader, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore });

        var document = XDocument.Load(xmlReader);

        var fragments = document.Descendants("page").SelectMany(page =>
        {
            var pageNumber = int.Parse(page.Attribute("number")?.Value ?? "1");
            return page.Elements("text").Select(item => new PositionedTextFragment(pageNumber, int.Parse(item.Attribute("top")?.Value ?? throw new InvalidOperationException("Missing text top attribute.")), int.Parse(item.Attribute("left")?.Value ?? throw new InvalidOperationException("Missing text left attribute.")), int.Parse(item.Attribute("width")?.Value ?? throw new InvalidOperationException("Missing text width attribute.")), int.Parse(item.Attribute("height")?.Value ?? throw new InvalidOperationException("Missing text height attribute.")), NormalizeText(string.Concat(item.DescendantNodes().OfType<XText>().Select(node => node.Value))))).Where(item => !string.IsNullOrWhiteSpace(item.Text));
        }).ToList();

        return RemoveRedundantContainedFragments(fragments);
    }

    internal static List<ParsedPdfPageGeometry> LoadPageGeometries(string xmlContent)
    {
        using var stringReader = new StringReader(xmlContent);
        using var xmlReader = XmlReader.Create(stringReader, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore });

        var document = XDocument.Load(xmlReader);

        return document.Descendants("page").Select(page => new ParsedPdfPageGeometry(int.Parse(page.Attribute("number")?.Value ?? "1"), int.Parse(page.Attribute("width")?.Value ?? throw new InvalidOperationException("Missing page width attribute.")), int.Parse(page.Attribute("height")?.Value ?? throw new InvalidOperationException("Missing page height attribute.")))).ToList();
    }

    internal static List<PositionedTextFragment> FindRowLabelFragments(IReadOnlyList<PositionedTextFragment> fragments, int leftCutoff, int bodyStartTop, int keyTop)
    {
        var candidates = fragments.Where(item => item.Left < leftCutoff && item.Top >= bodyStartTop && item.Top < keyTop - FooterLabelExclusionGap && (IsRollBandLabel(item.Text) || LooksLikeSplitRollBandStart(item.Text))).OrderBy(item => item.Top).ThenBy(item => item.Left).ToList();

        var merged = new List<PositionedTextFragment>();

        for (var index = 0; index < candidates.Count; index++)
        {
            var candidate = candidates[index];
            if (TryMergeSplitRollBand(candidates, index, out var mergedCandidate))
            {
                merged.Add(mergedCandidate);
                index++;
                continue;
            }

            if (IsRollBandLabel(candidate.Text))
            {
                merged.Add(candidate);
            }
        }

        var deduped = new List<PositionedTextFragment>();

        foreach (var candidate in merged)
        {
            var previous = deduped.LastOrDefault();
            if (previous is not null && string.Equals(NormalizeRollBandLabel(previous.Text), NormalizeRollBandLabel(candidate.Text), StringComparison.OrdinalIgnoreCase) && Math.Abs(previous.Top - candidate.Top) <= RowLabelDuplicateTolerance)
            {
                continue;
            }

            deduped.Add(candidate);
        }

        return deduped;
    }

    internal static bool IsRollBandLabel(string value) =>
        TryNormalizeRollBandLabel(value, out _);

    internal static bool IsPotentialRowLabelFragment(PositionedTextFragment fragment, int leftCutoff) =>
        fragment.Left < leftCutoff && (IsRollBandLabel(fragment.Text) || LooksLikeSplitRollBandStart(fragment.Text));

    internal static string NormalizeRollBandLabel(string label) =>
        TryNormalizeRollBandLabel(label, out var normalized) ? normalized : Regex.Replace(CollapseWhitespace(label), @"\s*-\s*", "-");

    internal static ParsedCriticalRollBand CreateRollBand(string label, int sortOrder)
    {
        var normalizedLabel = NormalizeRollBandLabel(label);
        if (normalizedLabel.EndsWith('+'))
        {
            return new ParsedCriticalRollBand(normalizedLabel, int.Parse(normalizedLabel[..^1]), null, sortOrder);
        }

        var parts = normalizedLabel.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        return parts.Length == 1 ? new ParsedCriticalRollBand(normalizedLabel, int.Parse(parts[0]), int.Parse(parts[0]), sortOrder) : new ParsedCriticalRollBand(normalizedLabel, int.Parse(parts[0]), int.Parse(parts[1]), sortOrder);
    }

    internal static string ResolveColumn(double centerX, IReadOnlyList<(string Key, double CenterX)> columns)
    {
        for (var index = 0; index < columns.Count - 1; index++)
        {
            var boundary = (columns[index].CenterX + columns[index + 1].CenterX) / 2.0;
            if (centerX < boundary)
            {
                return columns[index].Key;
            }
        }

        return columns[^1].Key;
    }

    internal static IReadOnlyList<ColumnarCellLine> BuildLines(IReadOnlyList<PositionedTextFragment> fragments)
    {
        var lines = new List<List<PositionedTextFragment>>();

        foreach (var fragment in fragments.OrderBy(item => item.Top).ThenBy(item => item.Left))
        {
            if (lines.Count == 0 || Math.Abs(lines[^1][0].Top - fragment.Top) > TopGroupingTolerance)
            {
                lines.Add([fragment]);
                continue;
            }

            lines[^1].Add(fragment);
        }

        return lines.Select(line => new ColumnarCellLine(CollapseWhitespace(string.Join(' ', line.OrderBy(item => item.Left).Select(item => item.Text))), line.OrderBy(item => item.Left).ToList())).Where(item => !string.IsNullOrWhiteSpace(item.Text)).ToList();
    }

    internal static bool IsAffixLikeLine(string line, IReadOnlySet<string> affixLegendSymbols)
    {
        var value = line.Trim();
        if (value.Length == 0)
        {
            return false;
        }

        if (value is "-" or "\u2013" or "\u2014")
        {
            return true;
        }

        if (IsConditionalBranchStartLine(value))
        {
            return true;
        }

        if (affixLegendSymbols.Count > 0 && affixLegendSymbols.Any(symbol => value.Contains(symbol, StringComparison.Ordinal)))
        {
            if (value.Any(char.IsDigit))
            {
                return true;
            }

            var remainder = value;
            foreach (var symbol in affixLegendSymbols.OrderByDescending(item => item.Length))
            {
                remainder = remainder.Replace(symbol, string.Empty, StringComparison.Ordinal);
            }

            remainder = remainder.Replace("+", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal).Replace("–", string.Empty, StringComparison.Ordinal).Replace("(", string.Empty, StringComparison.Ordinal).Replace(")", string.Empty, StringComparison.Ordinal).Replace("/", string.Empty, StringComparison.Ordinal);

            if (string.IsNullOrWhiteSpace(remainder))
            {
                return true;
            }
        }

        return value.StartsWith("+", StringComparison.Ordinal) || value.StartsWith("\u2211", StringComparison.Ordinal) || value.StartsWith("\u220F", StringComparison.Ordinal) || value.StartsWith("\u03C0", StringComparison.Ordinal) || value.StartsWith("\u222B", StringComparison.Ordinal) || StandaloneModifierAffixLineRegex.IsMatch(value) || NumericAffixLineRegex.IsMatch(value) || value.Contains(" - ", StringComparison.Ordinal) || value.Contains(" – ", StringComparison.Ordinal);
    }

    internal static int CountLineTypeSegments(IReadOnlyList<string> lines, IReadOnlySet<string> affixLegendSymbols)
    {
        var segmentCount = 0;
        bool? previousIsAffix = null;

        foreach (var line in lines)
        {
            var currentIsAffix = IsAffixLikeLine(line, affixLegendSymbols);
            if (previousIsAffix == currentIsAffix)
            {
                continue;
            }

            segmentCount++;
            previousIsAffix = currentIsAffix;
        }

        return segmentCount;
    }

    internal static string CollapseWhitespace(string value) =>
        Regex.Replace(value.Trim(), @"\s+", " ");

    internal static bool IsConditionalBranchStartLine(string value)
    {
        var normalized = value.Trim();
        if (!normalized.Contains(':', StringComparison.Ordinal))
        {
            return false;
        }

        return normalized.StartsWith("with ", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("w/ ", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("w/o ", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("without ", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("if ", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("while ", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("until ", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("unless ", StringComparison.OrdinalIgnoreCase);
    }

    internal static string NormalizeText(string value) =>
        value.Replace('\u00a0', ' ').Replace('\r', ' ').Replace('\n', ' ').Replace('’', '\'').Trim();

    private static List<PositionedTextFragment> RemoveRedundantContainedFragments(IReadOnlyList<PositionedTextFragment> fragments)
    {
        var redundant = new HashSet<PositionedTextFragment>();

        foreach (var group in fragments.GroupBy(item => (item.PageNumber, item.Top, item.Height)))
        {
            var ordered = group.OrderByDescending(item => item.Width).ThenBy(item => item.Left).ToList();

            for (var index = 0; index < ordered.Count; index++)
            {
                var container = ordered[index];
                if (container.Text.Length <= 1)
                {
                    continue;
                }

                for (var candidateIndex = index + 1; candidateIndex < ordered.Count; candidateIndex++)
                {
                    var candidate = ordered[candidateIndex];
                    if (candidate.Width > container.Width || !container.Text.Contains(candidate.Text, StringComparison.Ordinal) || !IsHorizontallyContained(candidate, container))
                    {
                        continue;
                    }

                    redundant.Add(candidate);
                }
            }
        }

        return fragments.Where(item => !redundant.Contains(item)).ToList();
    }

    private static bool IsHorizontallyContained(PositionedTextFragment candidate, PositionedTextFragment container)
    {
        const int containmentTolerance = 1;

        var candidateRight = candidate.Left + candidate.Width;
        var containerRight = container.Left + container.Width;

        return candidate.Left >= container.Left - containmentTolerance && candidateRight <= containerRight + containmentTolerance;
    }

    internal static string? NormalizeConditionKey(string conditionText)
    {
        var normalized = CollapseWhitespace(conditionText).ToLowerInvariant().Replace("w/o", "without", StringComparison.Ordinal).Replace("w/", "with", StringComparison.Ordinal);
        normalized = Regex.Replace(normalized, @"[^a-z0-9]+", "_");
        normalized = normalized.Trim('_');
        return normalized.Length == 0 ? null : normalized;
    }

    internal static int FindKeyTop(IReadOnlyList<PositionedTextFragment> fragments) =>
        fragments.Where(item => string.Equals(item.Text, "Key:", StringComparison.OrdinalIgnoreCase) || item.Text.Contains("must parry", StringComparison.OrdinalIgnoreCase) || item.Text.Contains("attacker gets", StringComparison.OrdinalIgnoreCase)).Select(item => (int?)item.Top).Min() ?? int.MaxValue;

    internal static AffixLegend ParseAffixLegend(IReadOnlyList<PositionedTextFragment> fragments, int keyTop)
    {
        if (keyTop == int.MaxValue)
        {
            return AffixLegend.Empty;
        }

        var footerLines = GroupByTop(fragments.Where(item => item.Top >= keyTop - TopGroupingTolerance).OrderBy(item => item.Top).ThenBy(item => item.Left).ToList()).Select(line => CollapseWhitespace(string.Join(' ', line.OrderBy(item => item.Left).Select(item => item.Text)))).ToList();

        var footerText = string.Join(' ', footerLines);
        var symbolEffects = new Dictionary<string, string>(StringComparer.Ordinal);

        AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.MustParryRounds, @"must parry\s*=\s*(\S)");
        AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.MustParryRounds, @"(\S)\s*=\s*must parry");
        AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.NoParryRounds, @"no parry\s*=\s*(\S)");
        AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.NoParryRounds, @"(\S)\s*=\s*no parry");
        AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.StunnedRounds, @"stun(?:ned)?\s*=\s*(\S)");
        AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.StunnedRounds, @"(\S)\s*=\s*stun(?:ned)?");
        AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.BleedPerRound, @"bleed\s*=\s*(\S)");
        AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.BleedPerRound, @"(\S)\s*=\s*bleed");

        return new AffixLegend(symbolEffects, footerText.Contains("powerpoint modification", StringComparison.OrdinalIgnoreCase) ? ["P"] : [], supportsFoePenalty: footerText.Contains("foe has", StringComparison.OrdinalIgnoreCase) && footerText.Contains("penalty", StringComparison.OrdinalIgnoreCase), supportsAttackerBonus: footerText.Contains("attacker gets", StringComparison.OrdinalIgnoreCase) && footerText.Contains("next round", StringComparison.OrdinalIgnoreCase), supportsPowerPointModifier: footerText.Contains("powerpoint modification", StringComparison.OrdinalIgnoreCase));
    }

    internal static List<PositionedTextFragment> SplitBoundaryCrossingFragments(IReadOnlyList<PositionedTextFragment> bodyFragments, IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlySet<string> affixLegendSymbols)
    {
        var splitFragments = new List<PositionedTextFragment>(bodyFragments.Count);

        foreach (var fragment in bodyFragments)
        {
            splitFragments.AddRange(SplitBoundaryCrossingFragment(fragment, columnCenters, affixLegendSymbols));
        }

        return splitFragments;
    }

    internal static List<(int Top, bool IsAffixLike)> BuildBodyLines(IReadOnlyList<PositionedTextFragment> bodyFragments, IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlySet<string> affixLegendSymbols)
    {
        var bodyLines = new List<(int Top, bool IsAffixLike)>();

        foreach (var lineFragments in GroupByTop(bodyFragments.OrderBy(item => item.Top).ThenBy(item => item.Left).ToList()))
        {
            var columnTexts = lineFragments.GroupBy(item => ResolveColumn(item.CenterX, columnCenters), StringComparer.OrdinalIgnoreCase).Select(group => CollapseWhitespace(string.Join(' ', group.OrderBy(item => item.Left).Select(item => item.Text)))).Where(item => !string.IsNullOrWhiteSpace(item)).ToList();

            var isAffixLike = columnTexts.Count > 0 && columnTexts.All(text => IsAffixLikeLine(text, affixLegendSymbols) || IsBoundaryBonusLine(text));

            bodyLines.Add((lineFragments[0].Top, isAffixLike));
        }

        return bodyLines;
    }

    internal static bool IsFooterPageNumberFragment(PositionedTextFragment fragment, int keyTop)
    {
        if (keyTop == int.MaxValue)
        {
            return false;
        }

        return fragment.Top >= keyTop - FooterPageNumberExclusionGap && Regex.IsMatch(fragment.Text, @"^\d{2,3}$");
    }

    internal static IEnumerable<List<PositionedTextFragment>> GroupByTop(IReadOnlyList<PositionedTextFragment> fragments)
    {
        var groups = new List<List<PositionedTextFragment>>();

        foreach (var fragment in fragments)
        {
            if (groups.Count == 0 || Math.Abs(groups[^1][0].Top - fragment.Top) > TopGroupingTolerance)
            {
                groups.Add([fragment]);
                continue;
            }

            groups[^1].Add(fragment);
        }

        return groups;
    }

    internal static List<RowAnchor> CreateRowAnchors(IReadOnlyList<PositionedTextFragment> rowLabelFragments) =>
        rowLabelFragments.OrderBy(item => item.Top).Select((item, index) => new RowAnchor(NormalizeRollBandLabel(item.Text), item.Top, index + 1)).ToList();

    internal static int ResolveBodyStartTop(int headerTop, IReadOnlyList<RowAnchor> rowAnchors)
    {
        if (rowAnchors.Count == 0)
        {
            return headerTop + HeaderToBodyMinimumGap;
        }

        return Math.Min(headerTop + HeaderToBodyMinimumGap, Math.Max(headerTop + HeaderToRowLabelMinimumGap, rowAnchors[0].Top - HeaderToRowLabelMinimumGap - TopGroupingTolerance));
    }

    internal static List<PositionedTextFragment> BuildBodyFragments(IReadOnlyList<PositionedTextFragment> fragments, int bodyStartTop, int keyTop, int leftCutoff, IReadOnlyList<RowAnchor> rowAnchors, IReadOnlyCollection<PositionedTextFragment> excludedFragments, IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlySet<string> affixLegendSymbols)
    {
        var bodyFragments = fragments.Where(item => item.Top >= bodyStartTop && item.Top < keyTop - TopGroupingTolerance && !IsFooterPageNumberFragment(item, keyTop) && !IsPotentialRowLabelFragment(item, leftCutoff) && !rowAnchors.Any(anchor => anchor.Top == item.Top && string.Equals(anchor.Label, NormalizeRollBandLabel(item.Text), StringComparison.OrdinalIgnoreCase)) && !excludedFragments.Contains(item)).ToList();

        return SplitBoundaryCrossingFragments(bodyFragments, columnCenters, affixLegendSymbols);
    }

    internal static void RepairLeadingAffixLeakage(List<ColumnarCellEntry> cellEntries, IReadOnlySet<string> affixLegendSymbols)
    {
        var maxRowIndex = cellEntries.Count == 0 ? -1 : cellEntries.Max(item => item.RowIndex);
        var axes = cellEntries.Select(item => (item.GroupKey, item.ColumnKey)).Distinct().ToList();

        for (var rowIndex = 0; rowIndex < maxRowIndex; rowIndex++)
        {
            foreach (var (groupKey, columnKey) in axes)
            {
                var current = cellEntries.SingleOrDefault(item => item.RowIndex == rowIndex && string.Equals(item.GroupKey, groupKey, StringComparison.Ordinal) && string.Equals(item.ColumnKey, columnKey, StringComparison.Ordinal));
                var next = cellEntries.SingleOrDefault(item => item.RowIndex == rowIndex + 1 && string.Equals(item.GroupKey, groupKey, StringComparison.Ordinal) && string.Equals(item.ColumnKey, columnKey, StringComparison.Ordinal));
                if (current is null || next is null)
                {
                    continue;
                }

                var leadingAffixCount = 0;
                while (leadingAffixCount < next.Lines.Count && IsAffixLikeLine(next.Lines[leadingAffixCount].Text, affixLegendSymbols))
                {
                    leadingAffixCount++;
                }

                if (leadingAffixCount == 0 || leadingAffixCount == next.Lines.Count)
                {
                    continue;
                }

                current.Lines.AddRange(next.Lines.Take(leadingAffixCount));
                next.Lines.RemoveRange(0, leadingAffixCount);
            }
        }
    }

    internal static int ResolveRowBoundaryTop(RowAnchor current, RowAnchor next, IReadOnlyList<(int Top, bool IsAffixLike)> bodyLines)
    {
        var linesBetweenLabels = bodyLines.Where(item => item.Top >= current.Top && item.Top < next.Top).OrderBy(item => item.Top).ToList();

        for (var index = linesBetweenLabels.Count - 2; index >= 0; index--)
        {
            if (linesBetweenLabels[index].IsAffixLike && !linesBetweenLabels[index + 1].IsAffixLike)
            {
                return (int)Math.Floor((linesBetweenLabels[index].Top + linesBetweenLabels[index + 1].Top) / 2.0) + 1;
            }
        }

        return (int)Math.Floor((current.Top + next.Top) / 2.0) + 1;
    }

    internal static void BuildParsedArtifacts(IReadOnlyList<ColumnarCellEntry> cellEntries, AffixLegend affixLegend, List<ParsedCriticalCellArtifact> parsedCells, List<ParsedCriticalResult> parsedResults, List<string> validationErrors, List<string>? validationWarnings = null, bool downgradeCellContentValidationToWarnings = false)
    {
        var sharedLegend = ToSharedAffixLegend(affixLegend);

        foreach (var cellEntry in cellEntries)
        {
            var lineTexts = cellEntry.Lines.Select(line => line.Text).ToList();
            var content = SharedParsing.CriticalCellTextParser.Parse(lineTexts, sharedLegend);
            var sourceBounds = BuildSourceBounds(cellEntry.Lines.SelectMany(line => line.Fragments).ToList());
            var contentIssues = content.ValidationErrors.Select(error => $"Cell '{BuildCellIdentifier(cellEntry)}': {error}");
            if (downgradeCellContentValidationToWarnings)
            {
                validationWarnings?.AddRange(contentIssues);
            }
            else
            {
                validationErrors.AddRange(contentIssues);
            }

            var effects = content.Effects.Select(ToImportToolEffect).ToList();
            var branches = content.Branches.Select(ToImportToolBranch).ToList();

            parsedCells.Add(new ParsedCriticalCellArtifact(cellEntry.GroupKey, cellEntry.RollBandLabel, cellEntry.ColumnKey, lineTexts, content.BaseLines, content.RawCellText, content.DescriptionText, content.RawAffixText, effects, branches, sourceBounds));

            parsedResults.Add(new ParsedCriticalResult(cellEntry.GroupKey, cellEntry.ColumnKey, cellEntry.RollBandLabel, content.RawCellText, content.DescriptionText, content.RawAffixText, effects, branches, sourceBounds));
        }
    }

    private static ParsedCriticalSourceRect BuildSourceBounds(IReadOnlyList<PositionedTextFragment> fragments)
    {
        if (fragments.Count == 0)
        {
            throw new InvalidOperationException("Cannot build source bounds for an empty fragment set.");
        }

        var pageNumber = fragments[0].PageNumber;
        if (fragments.Any(fragment => fragment.PageNumber != pageNumber))
        {
            throw new InvalidOperationException("A parsed cell spans multiple PDF pages, which cannot be cropped reliably.");
        }

        var left = fragments.Min(fragment => fragment.Left);
        var top = fragments.Min(fragment => fragment.Top);
        var right = fragments.Max(fragment => fragment.Left + fragment.Width);
        var bottom = fragments.Max(fragment => fragment.Top + fragment.Height);

        return new ParsedCriticalSourceRect(pageNumber, left, top, Math.Max(1, right - left), Math.Max(1, bottom - top));
    }

    private static SharedParsing.AffixLegend ToSharedAffixLegend(AffixLegend affixLegend) =>
        new(affixLegend.SymbolEffects, affixLegend.ClassificationSymbols.Except(affixLegend.EffectSymbols).ToList(), affixLegend.SupportsFoePenalty, affixLegend.SupportsAttackerBonus, affixLegend.SupportsPowerPointModifier);

    private static ParsedCriticalEffect ToImportToolEffect(SharedParsing.ParsedCriticalEffect effect) =>
        new(effect.EffectCode, effect.Target, effect.ValueInteger, effect.ValueExpression, effect.DurationRounds, effect.PerRound, effect.Modifier, effect.BodyPart, effect.IsPermanent, effect.SourceType, effect.SourceText);

    private static ParsedCriticalBranch ToImportToolBranch(SharedParsing.ParsedCriticalBranch branch) =>
        new(branch.BranchKind, branch.ConditionKey, branch.ConditionText, branch.RawText, branch.DescriptionText, branch.RawAffixText, branch.Effects.Select(ToImportToolEffect).ToList(), branch.SortOrder);

    private static string BuildCellIdentifier(ColumnarCellEntry cellEntry) =>
        cellEntry.GroupKey is null ? $"{cellEntry.RollBandLabel}/{cellEntry.ColumnKey}" : $"{cellEntry.RollBandLabel}/{cellEntry.GroupKey}/{cellEntry.ColumnKey}";

    private static bool LooksLikeSplitRollBandStart(string value) =>
        Regex.IsMatch(value.Trim(), @"^\d{2,3}\s*-$");

    private static bool TryNormalizeRollBandLabel(string label, out string normalized)
    {
        normalized = Regex.Replace(CollapseWhitespace(label), @"\s*-\s*", "-");

        if (RollBandLabelRegex.IsMatch(normalized))
        {
            return true;
        }

        if (OcrDamagedStandardRollBandLabels.TryGetValue(normalized, out var repaired))
        {
            normalized = repaired;
            return true;
        }

        return false;
    }

    private static bool TryMergeSplitRollBand(IReadOnlyList<PositionedTextFragment> candidates, int index, out PositionedTextFragment mergedCandidate)
    {
        var current = candidates[index];
        if (!LooksLikeSplitRollBandStart(current.Text) || index + 1 >= candidates.Count)
        {
            mergedCandidate = null!;
            return false;
        }

        var next = candidates[index + 1];
        if (current.PageNumber != next.PageNumber || !Regex.IsMatch(next.Text.Trim(), @"^\d{2,3}$") || next.Top <= current.Top || next.Top - current.Top > RowLabelDuplicateTolerance + 5 || Math.Abs(next.Left - current.Left) > 20)
        {
            mergedCandidate = null!;
            return false;
        }

        var startDigits = Regex.Match(current.Text, @"\d{2,3}").Value;
        var mergedLabel = $"{startDigits}-{next.Text.Trim()}";
        var right = Math.Max(current.Left + current.Width, next.Left + next.Width);

        mergedCandidate = new PositionedTextFragment(current.PageNumber, current.Top, Math.Min(current.Left, next.Left), right - Math.Min(current.Left, next.Left), Math.Max(current.Height, next.Height), mergedLabel);
        return true;
    }

    private static IReadOnlyList<PositionedTextFragment> SplitBoundaryCrossingFragment(PositionedTextFragment fragment, IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlySet<string> affixLegendSymbols)
    {
        if (!CrossesColumnBoundary(fragment, columnCenters))
        {
            return [fragment];
        }

        if (IsAffixLikeLine(fragment.Text, affixLegendSymbols) && fragment.Text.Contains("  ", StringComparison.Ordinal))
        {
            return BuildSplitFragmentsFromMatches(fragment, MultiFragmentSplitRegex.Matches(fragment.Text), columnCenters);
        }

        if (TrySplitProseFragmentAtBoundaries(fragment, columnCenters, out var splitFragments))
        {
            return splitFragments;
        }

        return [fragment];
    }

    private static IReadOnlyList<PositionedTextFragment> BuildSplitFragmentsFromMatches(PositionedTextFragment fragment, MatchCollection matches, IReadOnlyList<(string Key, double CenterX)> columnCenters)
    {
        if (matches.Count < 2)
        {
            return [fragment];
        }

        var characterWidth = fragment.Width / (double)Math.Max(fragment.Text.Length, 1);
        var splitFragments = new List<PositionedTextFragment>(matches.Count);

        foreach (Match match in matches)
        {
            var segmentText = CollapseWhitespace(match.Value);
            if (segmentText.Length == 0)
            {
                continue;
            }

            var segmentLeft = fragment.Left + (int)Math.Round(characterWidth * match.Index);
            var segmentWidth = Math.Max(1, (int)Math.Round(characterWidth * match.Length));

            splitFragments.Add(new PositionedTextFragment(fragment.PageNumber, fragment.Top, segmentLeft, segmentWidth, fragment.Height, segmentText));
        }

        if (splitFragments.Count < 2)
        {
            return [fragment];
        }

        var originalColumn = ResolveColumn(fragment.CenterX, columnCenters);
        var distinctColumns = splitFragments.Select(item => ResolveColumn(item.CenterX, columnCenters)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();

        return distinctColumns.Count > 1 || distinctColumns.Any(item => !string.Equals(item, originalColumn, StringComparison.OrdinalIgnoreCase)) ? splitFragments : [fragment];
    }

    private static bool TrySplitProseFragmentAtBoundaries(PositionedTextFragment fragment, IReadOnlyList<(string Key, double CenterX)> columnCenters, out IReadOnlyList<PositionedTextFragment> splitFragments)
    {
        splitFragments = null!;

        var boundaryIndexes = FindBoundarySplitIndexes(fragment, columnCenters);
        if (boundaryIndexes.Count == 0)
        {
            return false;
        }

        var segments = new List<PositionedTextFragment>();
        var segmentStart = 0;
        var characterWidth = fragment.Width / (double)Math.Max(fragment.Text.Length, 1);

        foreach (var splitIndex in boundaryIndexes)
        {
            var segment = CreateFragmentSegment(fragment, segmentStart, splitIndex - segmentStart, characterWidth);
            if (segment is not null)
            {
                segments.Add(segment);
            }

            segmentStart = splitIndex;
        }

        var trailingSegment = CreateFragmentSegment(fragment, segmentStart, fragment.Text.Length - segmentStart, characterWidth);
        if (trailingSegment is not null)
        {
            segments.Add(trailingSegment);
        }

        if (segments.Count < 2)
        {
            return false;
        }

        splitFragments = segments;
        return true;
    }

    private static List<int> FindBoundarySplitIndexes(PositionedTextFragment fragment, IReadOnlyList<(string Key, double CenterX)> columnCenters)
    {
        var characterWidth = fragment.Width / (double)Math.Max(fragment.Text.Length, 1);
        var fragmentRight = fragment.Left + fragment.Width;
        var splitIndexes = new List<int>();
        var minimumIndex = 1;

        for (var index = 0; index < columnCenters.Count - 1; index++)
        {
            var boundary = (columnCenters[index].CenterX + columnCenters[index + 1].CenterX) / 2.0;
            if (fragment.Left >= boundary || fragmentRight <= boundary)
            {
                continue;
            }

            var targetIndex = (int)Math.Round((boundary - fragment.Left) / characterWidth);
            var splitIndex = FindNearestWhitespaceSplitIndex(fragment.Text, targetIndex, minimumIndex);
            if (splitIndex is null)
            {
                return [];
            }

            splitIndexes.Add(splitIndex.Value);
            minimumIndex = splitIndex.Value + 1;
        }

        return splitIndexes;
    }

    private static int? FindNearestWhitespaceSplitIndex(string text, int targetIndex, int minimumIndex)
    {
        var start = Math.Max(minimumIndex, targetIndex - BoundarySplitSearchRadiusChars);
        var end = Math.Min(text.Length - 1, targetIndex + BoundarySplitSearchRadiusChars);
        int? bestIndex = null;
        var bestDistance = int.MaxValue;

        for (var index = start; index <= end; index++)
        {
            if (!char.IsWhiteSpace(text[index]))
            {
                continue;
            }

            var candidate = index;
            while (candidate < text.Length && char.IsWhiteSpace(text[candidate]))
            {
                candidate++;
            }

            if (candidate <= minimumIndex || candidate >= text.Length)
            {
                continue;
            }

            var distance = Math.Abs(candidate - targetIndex);
            if (distance >= bestDistance)
            {
                continue;
            }

            bestDistance = distance;
            bestIndex = candidate;
        }

        return bestIndex;
    }

    private static PositionedTextFragment? CreateFragmentSegment(PositionedTextFragment fragment, int startIndex, int length, double characterWidth)
    {
        if (length <= 0)
        {
            return null;
        }

        var rawSegment = fragment.Text.Substring(startIndex, length);
        var trimmedStart = 0;
        while (trimmedStart < rawSegment.Length && char.IsWhiteSpace(rawSegment[trimmedStart]))
        {
            trimmedStart++;
        }

        var trimmedEnd = rawSegment.Length - 1;
        while (trimmedEnd >= trimmedStart && char.IsWhiteSpace(rawSegment[trimmedEnd]))
        {
            trimmedEnd--;
        }

        if (trimmedEnd < trimmedStart)
        {
            return null;
        }

        var actualStart = startIndex + trimmedStart;
        var actualLength = trimmedEnd - trimmedStart + 1;
        var segmentText = CollapseWhitespace(fragment.Text.Substring(actualStart, actualLength));

        return new PositionedTextFragment(fragment.PageNumber, fragment.Top, fragment.Left + (int)Math.Round(characterWidth * actualStart), Math.Max(1, (int)Math.Round(characterWidth * actualLength)), fragment.Height, segmentText);
    }

    private static bool CrossesColumnBoundary(PositionedTextFragment fragment, IReadOnlyList<(string Key, double CenterX)> columnCenters)
    {
        var fragmentRight = fragment.Left + fragment.Width;

        for (var index = 0; index < columnCenters.Count - 1; index++)
        {
            var boundary = (columnCenters[index].CenterX + columnCenters[index + 1].CenterX) / 2.0;
            if (fragment.Left < boundary && fragmentRight > boundary)
            {
                return true;
            }
        }

        return false;
    }

    private static bool IsBoundaryBonusLine(string text) =>
        BoundaryBonusLineRegex.IsMatch(text.Trim());

    private static void AddLegendMatch(IDictionary<string, string> symbolEffects, string value, string effectCode, string pattern)
    {
        foreach (Match match in Regex.Matches(value, pattern, RegexOptions.IgnoreCase))
        {
            if (match.Groups.Count > 1)
            {
                var symbol = match.Groups[1].Value.Trim();
                if (symbol.Length == 1)
                {
                    symbolEffects[symbol] = effectCode;
                }
            }
        }
    }
}