using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using RolemasterDb.App.Data;
using RolemasterDb.App.Domain;
using SharedParsing = RolemasterDb.CriticalParsing;

namespace RolemasterDb.App.Features;

public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbContextFactory, CriticalImportArtifactLocator? artifactLocator = null)
{
    public async Task<LookupReferenceData> GetReferenceDataAsync(CancellationToken cancellationToken = default)
    {
        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        var attackTables = await dbContext.AttackTables.AsNoTracking().OrderBy(item => item.DisplayName).Select(item => new AttackTableReference(item.Slug, item.DisplayName, item.AttackKind, item.FumbleMinRoll, item.FumbleMaxRoll)).ToListAsync(cancellationToken);

        var armorTypes = await dbContext.ArmorTypes.AsNoTracking().OrderBy(item => item.SortOrder).Select(item => new LookupOption(item.Code, item.Label)).ToListAsync(cancellationToken);

        var criticalTables = await dbContext.CriticalTables.AsNoTracking().AsSplitQuery().Include(item => item.Columns).Include(item => item.Groups).Include(item => item.RollBands).OrderBy(item => item.DisplayName).ToListAsync(cancellationToken);

        var criticalResultCounts = await dbContext.CriticalResults.AsNoTracking().GroupBy(item => item.CriticalTableId).Select(group => new
        {
            CriticalTableId = group.Key,
            TotalCount = group.Count(),
            CuratedCount = group.Count(item => item.IsCurated)
        }).ToDictionaryAsync(item => item.CriticalTableId, item => (item.CuratedCount, item.TotalCount), cancellationToken);

        return new LookupReferenceData(attackTables, armorTypes, criticalTables.Select(item => new CriticalTableReference(item.Slug, item.DisplayName, item.Family, item.SourceDocument, item.Notes, GetCuratedCount(item.Id), GetTotalCount(item.Id), GetCurationPercentage(item.Id), item.Columns.OrderBy(column => column.SortOrder).Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder)).ToList(), item.Groups.OrderBy(group => group.SortOrder).Select(group => new CriticalGroupReference(group.GroupKey, group.Label, group.SortOrder)).ToList(), item.RollBands.OrderBy(rollBand => rollBand.SortOrder).Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder)).ToList())).ToList());

        int GetCuratedCount(int tableId) =>
            criticalResultCounts.TryGetValue(tableId, out var counts) ? counts.CuratedCount : 0;

        int GetTotalCount(int tableId) =>
            criticalResultCounts.TryGetValue(tableId, out var counts) ? counts.TotalCount : 0;

        int GetCurationPercentage(int tableId)
        {
            if (!criticalResultCounts.TryGetValue(tableId, out var counts) || counts.TotalCount == 0)
            {
                return 0;
            }

            return (int)Math.Round((double)counts.CuratedCount * 100 / counts.TotalCount, MidpointRounding.AwayFromZero);
        }
    }

    public async Task<AttackLookupResponse?> LookupAttackAsync(AttackLookupRequest request, CancellationToken cancellationToken = default)
    {
        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        var attackTable = NormalizeSlug(request.AttackTable);
        var armorType = request.ArmorType.Trim().ToUpperInvariant();

        var attackResult = await dbContext.AttackResults.AsNoTracking().Where(item => item.AttackTable.Slug == attackTable && item.ArmorType.Code == armorType && request.Roll >= item.AttackRollBand.MinRoll && (item.AttackRollBand.MaxRoll == null || request.Roll <= item.AttackRollBand.MaxRoll)).Select(item => new AttackLookupResponse(item.AttackTable.Slug, item.AttackTable.DisplayName, item.ArmorType.Code, item.ArmorType.Label, request.Roll, item.AttackRollBand.Label, item.Hits, item.CriticalType, item.CriticalSeverity, item.RawNotation, item.Notes, null)).SingleOrDefaultAsync(cancellationToken);

        if (attackResult is null || attackResult.CriticalType is null || attackResult.CriticalSeverity is null || request.CriticalRoll is null)
        {
            return attackResult;
        }

        var autoCritical = await LookupCriticalAsync(new CriticalLookupRequest(attackResult.CriticalType, attackResult.CriticalSeverity, request.CriticalRoll.Value, null), cancellationToken);

        return attackResult with { AutoCritical = autoCritical };
    }

    public async Task<CriticalLookupResponse?> LookupCriticalAsync(CriticalLookupRequest request, CancellationToken cancellationToken = default)
    {
        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        var criticalType = NormalizeSlug(request.CriticalType);
        var column = request.Column.Trim().ToUpperInvariant();
        var group = string.IsNullOrWhiteSpace(request.Group) ? null : NormalizeSlug(request.Group);

        return await dbContext.CriticalResults.AsNoTracking().AsSplitQuery().Where(item => item.CriticalTable.Slug == criticalType && item.CriticalColumn.ColumnKey == column && (group == null ? item.CriticalGroupId == null : item.CriticalGroup != null && item.CriticalGroup.GroupKey == group) && request.Roll >= item.CriticalRollBand.MinRoll && (item.CriticalRollBand.MaxRoll == null || request.Roll <= item.CriticalRollBand.MaxRoll)).Select(item => new CriticalLookupResponse(item.CriticalTable.Slug, item.CriticalTable.DisplayName, item.CriticalTable.Family, item.CriticalTable.SourceDocument, item.CriticalTable.Notes, item.CriticalGroup != null ? item.CriticalGroup.GroupKey : null, item.CriticalGroup != null ? item.CriticalGroup.Label : null, item.CriticalColumn.ColumnKey, item.CriticalColumn.Label, item.CriticalColumn.Role, request.Roll, item.CriticalRollBand.Label, item.CriticalRollBand.MinRoll, item.CriticalRollBand.MaxRoll, item.RawCellText, item.DescriptionText, item.RawAffixText, item.Effects.OrderBy(effect => effect.Id).Select(effect => new CriticalEffectLookupResponse(effect.EffectCode, effect.Target, effect.ValueInteger, effect.ValueExpression, effect.DurationRounds, effect.PerRound, effect.Modifier, effect.BodyPart, effect.IsPermanent, effect.SourceType, effect.SourceText)).ToList(), item.Branches.OrderBy(branch => branch.SortOrder).Select(branch => new CriticalBranchLookupResponse(branch.BranchKind, branch.ConditionKey, branch.ConditionText, branch.DescriptionText, branch.RawAffixText, branch.Effects.OrderBy(effect => effect.Id).Select(effect => new CriticalEffectLookupResponse(effect.EffectCode, effect.Target, effect.ValueInteger, effect.ValueExpression, effect.DurationRounds, effect.PerRound, effect.Modifier, effect.BodyPart, effect.IsPermanent, effect.SourceType, effect.SourceText)).ToList(), branch.RawText, branch.SortOrder)).ToList(), item.ParseStatus, item.ParsedJson)).SingleOrDefaultAsync(cancellationToken);
    }

    public async Task<CriticalTableDetail?> GetCriticalTableAsync(string slug, CancellationToken cancellationToken = default)
    {
        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        var table = await dbContext.CriticalTables.AsNoTracking().AsSplitQuery().Include(item => item.Columns).Include(item => item.Groups).Include(item => item.RollBands).Include(item => item.Results).ThenInclude(result => result.CriticalColumn).Include(item => item.Results).ThenInclude(result => result.CriticalGroup).Include(item => item.Results).ThenInclude(result => result.CriticalRollBand).Include(item => item.Results).ThenInclude(result => result.Effects).Include(item => item.Results).ThenInclude(result => result.Branches).ThenInclude(branch => branch.Effects).Where(item => item.Slug == slug).SingleOrDefaultAsync(cancellationToken);

        if (table is null)
        {
            return null;
        }

        var columns = table.Columns.OrderBy(column => column.SortOrder).Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder)).ToList();

        var groups = table.Groups.OrderBy(group => group.SortOrder).Select(group => new CriticalGroupReference(group.GroupKey, group.Label, group.SortOrder)).ToList();

        var rollBands = table.RollBands.OrderBy(rollBand => rollBand.SortOrder).Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder)).ToList();

        var cells = table.Results.OrderBy(result => result.CriticalRollBand.SortOrder).ThenBy(result => result.CriticalGroup?.SortOrder ?? 0).ThenBy(result => result.CriticalColumn.SortOrder).Select(result => new CriticalTableCellDetail(result.Id, result.CriticalRollBand.Label, result.CriticalColumn.ColumnKey, result.CriticalColumn.Label, result.CriticalColumn.Role, result.CriticalGroup?.GroupKey, result.CriticalGroup?.Label, result.IsCurated, result.DescriptionText, result.Effects.OrderBy(effect => effect.Id).Select(effect => CreateEffectLookupResponse(effect)).ToList(), result.Branches.OrderBy(branch => branch.SortOrder).Select(branch => CreateBranchLookupResponse(branch)).ToList())).ToList();

        var legend = BuildLegend(cells);

        return new CriticalTableDetail(table.Slug, table.DisplayName, table.Family, table.SourceDocument, table.Notes, columns, groups, rollBands, cells, legend);
    }

    public async Task<CriticalCellEditorResponse?> GetCriticalCellEditorAsync(string slug, int resultId, CancellationToken cancellationToken = default)
    {
        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        var normalizedSlug = NormalizeSlug(slug);
        var result = await dbContext.CriticalResults.AsNoTracking().AsSplitQuery().Include(item => item.CriticalTable).Include(item => item.CriticalColumn).Include(item => item.CriticalGroup).Include(item => item.CriticalRollBand).Include(item => item.Effects).Include(item => item.Branches).ThenInclude(branch => branch.Effects).SingleOrDefaultAsync(item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug, cancellationToken);

        if (result is null)
        {
            return null;
        }

        var currentState = CreateCurrentEditorState(result);
        var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken);
        return CreateCellEditorResponse(result, currentState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
    }

    public async Task<string?> GetCriticalSourceImagePathAsync(string slug, int resultId, CancellationToken cancellationToken = default)
    {
        if (artifactLocator is null)
        {
            return null;
        }

        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        var normalizedSlug = NormalizeSlug(slug);
        var relativePath = await dbContext.CriticalResults.AsNoTracking().Where(item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug).Select(item => item.SourceImagePath).SingleOrDefaultAsync(cancellationToken);

        var fullPath = artifactLocator.ResolveStoredPath(relativePath);
        return fullPath is not null && File.Exists(fullPath) ? fullPath : null;
    }

    public async Task<CriticalCellEditorResponse?> ReparseCriticalCellAsync(string slug, int resultId, CriticalCellUpdateRequest currentState, CancellationToken cancellationToken = default)
    {
        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        var normalizedSlug = NormalizeSlug(slug);
        var result = await dbContext.CriticalResults.AsNoTracking().AsSplitQuery().Include(item => item.CriticalTable).Include(item => item.CriticalColumn).Include(item => item.CriticalGroup).Include(item => item.CriticalRollBand).SingleOrDefaultAsync(item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug, cancellationToken);

        if (result is null)
        {
            return null;
        }

        var content = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken);
        var generatedState = CreateGeneratedEditorState(content);
        var mergedState = MergeGeneratedState(currentState, generatedState);
        return CreateCellEditorResponse(result, mergedState, content.ValidationErrors, CreateComparisonState(content));
    }

    public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync(string slug, int resultId, CriticalCellUpdateRequest request, CancellationToken cancellationToken = default)
    {
        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        var normalizedSlug = NormalizeSlug(slug);
        var result = await dbContext.CriticalResults.AsSplitQuery().Include(item => item.CriticalTable).Include(item => item.CriticalColumn).Include(item => item.CriticalGroup).Include(item => item.CriticalRollBand).Include(item => item.Effects).Include(item => item.Branches).ThenInclude(branch => branch.Effects).SingleOrDefaultAsync(item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug, cancellationToken);

        if (result is null)
        {
            return null;
        }

        var currentState = CreateCurrentEditorState(result);
        var hasEdits = HasCriticalCellEdits(currentState, request);

        result.RawCellText = request.RawCellText.Trim();
        result.DescriptionText = request.DescriptionText.Trim();
        result.RawAffixText = NormalizeOptionalText(request.RawAffixText);
        result.ParseStatus = request.ParseStatus.Trim();
        result.ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson();
        result.IsCurated = hasEdits && result.IsCurated ? false : request.IsCurated;

        ReplaceBaseEffects(dbContext, result, request.Effects);
        ReplaceBranches(dbContext, result, request.Branches);

        await dbContext.SaveChangesAsync(cancellationToken);

        var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.QuickParseInput, cancellationToken);
        var persistedState = request with { IsCurated = result.IsCurated };
        return CreateCellEditorResponse(result, persistedState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
    }

    private static IReadOnlyList<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
    {
        var seenCodes = new HashSet<string>(StringComparer.Ordinal);
        var legend = new List<CriticalTableLegendEntry>();

        foreach (var cell in cells)
        {
            var baseEffects = cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>();
            foreach (var effect in baseEffects)
            {
                TryAddLegendEntry(effect.EffectCode);
            }

            var branches = cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>();
            foreach (var branch in branches)
            {
                var branchEffects = branch.Effects ?? Array.Empty<CriticalEffectLookupResponse>();
                foreach (var effect in branchEffects)
                {
                    TryAddLegendEntry(effect.EffectCode);
                }
            }
        }

        return legend.OrderBy(item => item.Label, StringComparer.Ordinal).ToList();

        void TryAddLegendEntry(string effectCode)
        {
            if (!seenCodes.Add(effectCode))
            {
                return;
            }

            if (!AffixDisplayMap.TryGet(effectCode, out var info))
            {
                return;
            }

            legend.Add(new CriticalTableLegendEntry(effectCode, info.Symbol, info.Label, info.Description, info.Tooltip));
        }
    }

    private static CriticalEffectLookupResponse CreateEffectLookupResponse(CriticalEffect 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 CriticalCellEditorResponse CreateCellEditorResponse(CriticalResult result, CriticalCellUpdateRequest state, IReadOnlyList<string> validationMessages, CriticalCellComparisonState? generatedState)
    {
        var snapshotJson = CriticalCellEditorSnapshot.FromRequest(state).ToJson();

        return new(result.Id, result.CriticalTable.Slug, result.CriticalTable.DisplayName, result.CriticalTable.SourceDocument, result.CriticalRollBand.Label, result.CriticalGroup?.GroupKey, result.CriticalGroup?.Label, result.CriticalColumn.ColumnKey, result.CriticalColumn.Label, result.CriticalColumn.Role, state.IsCurated, result.SourcePageNumber, CreateSourceImageUrl(result), state.RawCellText, state.QuickParseInput, state.DescriptionText, state.RawAffixText, state.ParseStatus, snapshotJson, state.IsDescriptionOverridden, state.IsRawAffixTextOverridden, state.AreEffectsOverridden, state.AreBranchesOverridden, validationMessages.ToList(), state.Effects.ToList(), state.Branches.ToList(), generatedState);
    }

    private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
        new(branch.BranchKind, branch.ConditionKey, branch.ConditionText, branch.DescriptionText, branch.RawAffixText, (branch.Effects ?? Enumerable.Empty<CriticalEffect>()).OrderBy(effect => effect.Id).Select(effect => CreateEffectLookupResponse(effect)).ToList(), branch.RawText, branch.SortOrder);

    private static CriticalBranchEditorItem CreateBranchEditorItem(CriticalBranch branch, int branchIndex)
    {
        var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText);

        return new(branch.BranchKind, branch.ConditionKey, branch.ConditionText, branch.ConditionJson, branch.RawText, branch.DescriptionText, branch.RawAffixText, branch.ParsedJson, branch.SortOrder, originKey, false, false, (branch.Effects ?? Enumerable.Empty<CriticalEffect>()).OrderBy(effect => effect.Id).Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode))).ToList());
    }

    private static CriticalBranchEditorItem CreateBranchEditorItem(SharedParsing.ParsedCriticalBranch branch, int branchIndex)
    {
        var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText);

        return new(branch.BranchKind, branch.ConditionKey, branch.ConditionText, "{}", branch.RawText, branch.DescriptionText, branch.RawAffixText, SerializeParsedEffects(branch.Effects), branch.SortOrder, originKey, false, false, branch.Effects.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode))).ToList());
    }

    private static CriticalCellComparisonState CreateComparisonState(SharedParsing.CriticalCellParseContent content) =>
        new(content.DescriptionText, content.Effects.Select(CreateEffectLookupResponse).ToList(), content.Branches.OrderBy(branch => branch.SortOrder).Select(CreateBranchLookupResponse).ToList(), content.ValidationErrors.ToList(), content.TokenReviewIssues.Select(CreateTokenReviewItem).ToList());

    private static CriticalTokenReviewItem CreateTokenReviewItem(SharedParsing.CriticalTokenReviewIssue issue) =>
        new(issue.Scope, issue.ConditionText, issue.Token, issue.ReviewText);

    private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect, string originKey) =>
        new(effect.EffectCode, effect.Target, effect.ValueInteger, effect.ValueDecimal, effect.ValueExpression, effect.DurationRounds, effect.PerRound, effect.Modifier, effect.BodyPart, effect.IsPermanent, effect.SourceType, effect.SourceText, originKey, false);

    private static CriticalEffectEditorItem CreateEffectEditorItem(SharedParsing.ParsedCriticalEffect effect, string originKey) =>
        new(effect.EffectCode, effect.Target, effect.ValueInteger, null, effect.ValueExpression, effect.DurationRounds, effect.PerRound, effect.Modifier, effect.BodyPart, effect.IsPermanent, effect.SourceType, effect.SourceText, originKey, false);

    private static CriticalCellUpdateRequest CreateCurrentEditorState(CriticalResult result)
    {
        if (CriticalCellEditorSnapshot.TryParse(result.ParsedJson, out var snapshot) && snapshot is not null)
        {
            var snapshotQuickParseInput = string.IsNullOrWhiteSpace(snapshot.QuickParseInput) ? CriticalQuickNotationFormatter.Format(result.DescriptionText, snapshot.Effects, snapshot.Branches) : snapshot.QuickParseInput;

            return new CriticalCellUpdateRequest(result.RawCellText, snapshotQuickParseInput, result.DescriptionText, result.RawAffixText, result.ParseStatus, result.ParsedJson, result.IsCurated, snapshot.IsDescriptionOverridden, snapshot.IsRawAffixTextOverridden, snapshot.AreEffectsOverridden, snapshot.AreBranchesOverridden, snapshot.Effects.ToList(), snapshot.Branches.ToList());
        }

        var effects = result.Effects.OrderBy(effect => effect.Id).Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBaseEffectOriginKey(effectIndex, effect.EffectCode))).ToList();
        var branches = result.Branches.OrderBy(branch => branch.SortOrder).Select((branch, branchIndex) => CreateBranchEditorItem(branch, branchIndex)).ToList();

        return new CriticalCellUpdateRequest(result.RawCellText, CriticalQuickNotationFormatter.Format(result.DescriptionText, effects, branches), result.DescriptionText, result.RawAffixText, result.ParseStatus, result.ParsedJson, result.IsCurated, false, false, false, false, effects, branches);
    }

    private static CriticalCellUpdateRequest CreateGeneratedEditorState(SharedParsing.CriticalCellParseContent content)
    {
        var effects = content.Effects.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBaseEffectOriginKey(effectIndex, effect.EffectCode))).ToList();
        var branches = content.Branches.OrderBy(branch => branch.SortOrder).Select((branch, branchIndex) => CreateBranchEditorItem(branch, branchIndex)).ToList();

        return new CriticalCellUpdateRequest(RawCellText: string.Empty, QuickParseInput: content.RawCellText, DescriptionText: content.DescriptionText, RawAffixText: content.RawAffixText, ParseStatus: ResolveParseStatus(content.Effects, content.Branches), ParsedJson: SerializeParsedEffects(content.Effects), IsCurated: false, IsDescriptionOverridden: false, IsRawAffixTextOverridden: false, AreEffectsOverridden: false, AreBranchesOverridden: false, Effects: effects, Branches: branches);
    }

    private static CriticalCellUpdateRequest MergeGeneratedState(CriticalCellUpdateRequest currentState, CriticalCellUpdateRequest generatedState) =>
        new(currentState.RawCellText, currentState.QuickParseInput, currentState.IsDescriptionOverridden ? currentState.DescriptionText : generatedState.DescriptionText, currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText, generatedState.ParseStatus, generatedState.ParsedJson, currentState.IsCurated, currentState.IsDescriptionOverridden, currentState.IsRawAffixTextOverridden, currentState.AreEffectsOverridden, currentState.AreBranchesOverridden, currentState.AreEffectsOverridden ? currentState.Effects.ToList() : MergeEffectItems(currentState.Effects, generatedState.Effects), currentState.AreBranchesOverridden ? currentState.Branches.OrderBy(branch => branch.SortOrder).ToList() : MergeBranchItems(currentState.Branches, generatedState.Branches));

    private static List<CriticalBranchEditorItem> MergeBranchItems(IReadOnlyList<CriticalBranchEditorItem> currentBranches, IReadOnlyList<CriticalBranchEditorItem> generatedBranches)
    {
        var currentByOrigin = currentBranches.Where(branch => !string.IsNullOrWhiteSpace(branch.OriginKey)).ToDictionary(branch => branch.OriginKey!, StringComparer.Ordinal);
        var merged = new List<CriticalBranchEditorItem>(generatedBranches.Count);
        var matchedOrigins = new HashSet<string>(StringComparer.Ordinal);

        foreach (var generatedBranch in generatedBranches)
        {
            if (generatedBranch.OriginKey is not null && currentByOrigin.TryGetValue(generatedBranch.OriginKey, out var currentBranch))
            {
                matchedOrigins.Add(generatedBranch.OriginKey);

                if (currentBranch.IsOverridden)
                {
                    merged.Add(currentBranch);
                    continue;
                }

                merged.Add(generatedBranch with
                {
                    AreEffectsOverridden = currentBranch.AreEffectsOverridden,
                    Effects = currentBranch.AreEffectsOverridden ? currentBranch.Effects.ToList() : MergeEffectItems(currentBranch.Effects, generatedBranch.Effects)
                });
                continue;
            }

            merged.Add(generatedBranch);
        }

        merged.AddRange(currentBranches.Where(branch => branch.IsOverridden && branch.OriginKey is not null && !matchedOrigins.Contains(branch.OriginKey)));

        return merged;
    }

    private static List<CriticalEffectEditorItem> MergeEffectItems(IReadOnlyList<CriticalEffectEditorItem> currentEffects, IReadOnlyList<CriticalEffectEditorItem> generatedEffects)
    {
        var currentByOrigin = currentEffects.Where(effect => !string.IsNullOrWhiteSpace(effect.OriginKey)).ToDictionary(effect => effect.OriginKey!, StringComparer.Ordinal);
        var merged = new List<CriticalEffectEditorItem>(generatedEffects.Count);
        var matchedOrigins = new HashSet<string>(StringComparer.Ordinal);

        foreach (var generatedEffect in generatedEffects)
        {
            if (generatedEffect.OriginKey is not null && currentByOrigin.TryGetValue(generatedEffect.OriginKey, out var currentEffect))
            {
                matchedOrigins.Add(generatedEffect.OriginKey);
                merged.Add(currentEffect.IsOverridden ? currentEffect : generatedEffect);
                continue;
            }

            merged.Add(generatedEffect);
        }

        merged.AddRange(currentEffects.Where(effect => effect.IsOverridden && effect.OriginKey is not null && !matchedOrigins.Contains(effect.OriginKey)));

        return merged;
    }

    private static string CreateBaseEffectOriginKey(int effectIndex, string effectCode) =>
        $"base:{NormalizeOriginSegment(effectCode)}:{effectIndex + 1}";

    private static string CreateBranchOriginKey(int branchIndex, string branchKind, string? conditionKey, string conditionText) =>
        $"branch:{NormalizeOriginSegment(branchKind)}:{NormalizeOriginSegment(conditionKey ?? conditionText)}:{branchIndex + 1}";

    private static string CreateBranchEffectOriginKey(string branchOriginKey, int effectIndex, string effectCode) =>
        $"{branchOriginKey}:effect:{NormalizeOriginSegment(effectCode)}:{effectIndex + 1}";

    private static string NormalizeOriginSegment(string value)
    {
        var normalized = new string(value.Trim().ToLowerInvariant().Select(character => char.IsLetterOrDigit(character) ? character : '_').ToArray()).Trim('_');

        return string.IsNullOrWhiteSpace(normalized) ? "empty" : normalized;
    }

    private static void ReplaceBaseEffects(RolemasterDbContext dbContext, CriticalResult result, IReadOnlyList<CriticalEffectEditorItem>? effects)
    {
        dbContext.CriticalEffects.RemoveRange(result.Effects);
        result.Effects.Clear();

        foreach (var effect in effects ?? Array.Empty<CriticalEffectEditorItem>())
        {
            result.Effects.Add(CreateEffectEntity(effect));
        }
    }

    private static void ReplaceBranches(RolemasterDbContext dbContext, CriticalResult result, IReadOnlyList<CriticalBranchEditorItem>? branches)
    {
        foreach (var branch in result.Branches)
        {
            dbContext.CriticalEffects.RemoveRange(branch.Effects);
        }

        dbContext.CriticalBranches.RemoveRange(result.Branches);
        result.Branches.Clear();

        foreach (var branch in branches ?? Array.Empty<CriticalBranchEditorItem>())
        {
            var branchEntity = new CriticalBranch
            {
                BranchKind = branch.BranchKind.Trim(),
                ConditionKey = NormalizeOptionalText(branch.ConditionKey),
                ConditionText = branch.ConditionText.Trim(),
                ConditionJson = string.IsNullOrWhiteSpace(branch.ConditionJson) ? "{}" : branch.ConditionJson.Trim(),
                RawText = branch.RawText.Trim(),
                DescriptionText = branch.DescriptionText.Trim(),
                RawAffixText = NormalizeOptionalText(branch.RawAffixText),
                ParsedJson = string.IsNullOrWhiteSpace(branch.ParsedJson) ? "{}" : branch.ParsedJson.Trim(),
                SortOrder = branch.SortOrder
            };

            foreach (var effect in branch.Effects ?? Array.Empty<CriticalEffectEditorItem>())
            {
                branchEntity.Effects.Add(CreateEffectEntity(effect));
            }

            result.Branches.Add(branchEntity);
        }
    }

    private static CriticalEffect CreateEffectEntity(CriticalEffectEditorItem effect) =>
        new()
        {
            EffectCode = effect.EffectCode.Trim(),
            Target = NormalizeOptionalText(effect.Target),
            ValueInteger = effect.ValueInteger,
            ValueDecimal = effect.ValueDecimal,
            ValueExpression = NormalizeOptionalText(effect.ValueExpression),
            DurationRounds = effect.DurationRounds,
            PerRound = effect.PerRound,
            Modifier = effect.Modifier,
            BodyPart = NormalizeOptionalText(effect.BodyPart),
            IsPermanent = effect.IsPermanent,
            SourceType = string.IsNullOrWhiteSpace(effect.SourceType) ? "manual" : effect.SourceType.Trim(),
            SourceText = NormalizeOptionalText(effect.SourceText)
        };

    private static CriticalEffectLookupResponse CreateEffectLookupResponse(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 CriticalBranchLookupResponse CreateBranchLookupResponse(SharedParsing.ParsedCriticalBranch branch) =>
        new(branch.BranchKind, branch.ConditionKey, branch.ConditionText, branch.DescriptionText, branch.RawAffixText, branch.Effects.Select(CreateEffectLookupResponse).ToList(), branch.RawText, branch.SortOrder);

    private static string ResolveParseStatus(IReadOnlyList<SharedParsing.ParsedCriticalEffect> effects, IReadOnlyList<SharedParsing.ParsedCriticalBranch> branches) =>
        effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0) ? "partial" : "raw";

    private static string SerializeParsedEffects(IReadOnlyList<SharedParsing.ParsedCriticalEffect> effects) =>
        effects.Count == 0
            ? "{}"
            : JsonSerializer.Serialize(new
            {
                effects = effects.Select(effect => new
                {
                    effect.EffectCode,
                    effect.Target,
                    effect.ValueInteger,
                    effect.ValueExpression,
                    effect.DurationRounds,
                    effect.PerRound,
                    effect.Modifier,
                    effect.BodyPart,
                    effect.IsPermanent,
                    effect.SourceType,
                    effect.SourceText
                }).ToList()
            });

    private static async Task<SharedParsing.AffixLegend> BuildSharedAffixLegendAsync(RolemasterDbContext dbContext, int tableId, CancellationToken cancellationToken)
    {
        var effectRows = await dbContext.CriticalEffects.AsNoTracking().Where(item => (item.CriticalResult != null && item.CriticalResult.CriticalTableId == tableId) || (item.CriticalBranch != null && item.CriticalBranch.CriticalResult.CriticalTableId == tableId)).Select(item => new
        {
            item.EffectCode,
            item.SourceType,
            item.SourceText
        }).ToListAsync(cancellationToken);

        var symbolEffects = new Dictionary<string, string>(StringComparer.Ordinal);
        var supportsFoePenalty = false;
        var supportsAttackerBonus = false;
        var supportsPowerPointModifier = false;

        foreach (var effectRow in effectRows)
        {
            supportsFoePenalty |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.FoePenalty, StringComparison.Ordinal);
            supportsAttackerBonus |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.AttackerBonusNextRound, StringComparison.Ordinal);
            supportsPowerPointModifier |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.PowerPointModifier, StringComparison.Ordinal);

            if (!string.Equals(effectRow.SourceType, "symbol", StringComparison.OrdinalIgnoreCase) || !IsLegendSymbolEffectCode(effectRow.EffectCode) || !TryExtractLegendSymbol(effectRow.SourceText, out var symbol))
            {
                continue;
            }

            symbolEffects.TryAdd(symbol, effectRow.EffectCode);
        }

        return new SharedParsing.AffixLegend(symbolEffects, supportsPowerPointModifier ? ["P"] : [], supportsFoePenalty, supportsAttackerBonus, supportsPowerPointModifier);
    }

    private static async Task<SharedParsing.CriticalCellParseContent> ParseCriticalCellContentAsync(RolemasterDbContext dbContext, int tableId, string quickParseInput, CancellationToken cancellationToken)
    {
        var affixLegend = await BuildSharedAffixLegendAsync(dbContext, tableId, cancellationToken);
        return SharedParsing.CriticalQuickNotationParser.Parse(quickParseInput, affixLegend);
    }

    private static bool IsLegendSymbolEffectCode(string effectCode) =>
        effectCode is CriticalEffectCodes.MustParryRounds or CriticalEffectCodes.NoParryRounds or CriticalEffectCodes.StunnedRounds or CriticalEffectCodes.BleedPerRound;

    private static bool TryExtractLegendSymbol(string? sourceText, out string symbol)
    {
        symbol = string.Empty;
        if (string.IsNullOrWhiteSpace(sourceText))
        {
            return false;
        }

        var candidate = new string(sourceText.Where(character => !char.IsWhiteSpace(character) && !char.IsDigit(character) && character is not ('+' or '-' or '–' or '(' or ')' or '/') && !char.IsLetter(character)).ToArray());

        if (string.IsNullOrWhiteSpace(candidate))
        {
            return false;
        }

        var distinctSymbols = candidate.Distinct().ToList();

        if (distinctSymbols.Count != 1)
        {
            return false;
        }

        symbol = distinctSymbols[0].ToString();
        return true;
    }

    private static string? NormalizeOptionalText(string? value) =>
        string.IsNullOrWhiteSpace(value) ? null : value.Trim();

    private static bool HasCriticalCellEdits(CriticalCellUpdateRequest currentState, CriticalCellUpdateRequest request)
    {
        if (!string.Equals(currentState.RawCellText.Trim(), request.RawCellText.Trim(), StringComparison.Ordinal) || !string.Equals(currentState.QuickParseInput.Trim(), request.QuickParseInput.Trim(), StringComparison.Ordinal) || !string.Equals(currentState.DescriptionText.Trim(), request.DescriptionText.Trim(), StringComparison.Ordinal) || !string.Equals(NormalizeOptionalText(currentState.RawAffixText), NormalizeOptionalText(request.RawAffixText), StringComparison.Ordinal) || !string.Equals(currentState.ParseStatus.Trim(), request.ParseStatus.Trim(), StringComparison.Ordinal) || currentState.IsDescriptionOverridden != request.IsDescriptionOverridden || currentState.IsRawAffixTextOverridden != request.IsRawAffixTextOverridden || currentState.AreEffectsOverridden != request.AreEffectsOverridden || currentState.AreBranchesOverridden != request.AreBranchesOverridden)
        {
            return true;
        }

        return !EffectListsEqual(currentState.Effects, request.Effects) || !BranchListsEqual(currentState.Branches, request.Branches);
    }

    private static bool EffectListsEqual(IReadOnlyList<CriticalEffectEditorItem> left, IReadOnlyList<CriticalEffectEditorItem> right)
    {
        if (left.Count != right.Count)
        {
            return false;
        }

        for (var index = 0; index < left.Count; index++)
        {
            if (!EffectsEqual(left[index], right[index]))
            {
                return false;
            }
        }

        return true;
    }

    private static bool BranchListsEqual(IReadOnlyList<CriticalBranchEditorItem> left, IReadOnlyList<CriticalBranchEditorItem> right)
    {
        if (left.Count != right.Count)
        {
            return false;
        }

        for (var index = 0; index < left.Count; index++)
        {
            if (!BranchesEqual(left[index], right[index]))
            {
                return false;
            }
        }

        return true;
    }

    private static bool EffectsEqual(CriticalEffectEditorItem left, CriticalEffectEditorItem right) =>
        string.Equals(left.EffectCode.Trim(), right.EffectCode.Trim(), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.Target), NormalizeOptionalText(right.Target), StringComparison.Ordinal) && left.ValueInteger == right.ValueInteger && left.ValueDecimal == right.ValueDecimal && string.Equals(NormalizeOptionalText(left.ValueExpression), NormalizeOptionalText(right.ValueExpression), StringComparison.Ordinal) && left.DurationRounds == right.DurationRounds && left.PerRound == right.PerRound && left.Modifier == right.Modifier && string.Equals(NormalizeOptionalText(left.BodyPart), NormalizeOptionalText(right.BodyPart), StringComparison.Ordinal) && left.IsPermanent == right.IsPermanent && string.Equals(left.SourceType.Trim(), right.SourceType.Trim(), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.SourceText), NormalizeOptionalText(right.SourceText), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.OriginKey), NormalizeOptionalText(right.OriginKey), StringComparison.Ordinal) && left.IsOverridden == right.IsOverridden;

    private static bool BranchesEqual(CriticalBranchEditorItem left, CriticalBranchEditorItem right) =>
        string.Equals(left.BranchKind.Trim(), right.BranchKind.Trim(), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.ConditionKey), NormalizeOptionalText(right.ConditionKey), StringComparison.Ordinal) && string.Equals(left.ConditionText.Trim(), right.ConditionText.Trim(), StringComparison.Ordinal) && string.Equals(left.ConditionJson.Trim(), right.ConditionJson.Trim(), StringComparison.Ordinal) && string.Equals(left.RawText.Trim(), right.RawText.Trim(), StringComparison.Ordinal) && string.Equals(left.DescriptionText.Trim(), right.DescriptionText.Trim(), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.RawAffixText), NormalizeOptionalText(right.RawAffixText), StringComparison.Ordinal) && string.Equals(left.ParsedJson.Trim(), right.ParsedJson.Trim(), StringComparison.Ordinal) && left.SortOrder == right.SortOrder && string.Equals(NormalizeOptionalText(left.OriginKey), NormalizeOptionalText(right.OriginKey), StringComparison.Ordinal) && left.IsOverridden == right.IsOverridden && left.AreEffectsOverridden == right.AreEffectsOverridden && EffectListsEqual(left.Effects, right.Effects);

    private static string NormalizeSlug(string value) =>
        value.Trim().Replace(' ', '_').ToLowerInvariant();

    private static string? CreateSourceImageUrl(CriticalResult result) =>
        string.IsNullOrWhiteSpace(result.SourceImagePath) ? null : $"/api/tables/critical/{result.CriticalTable.Slug}/cells/{result.Id}/source-image?v={CreateSourceImageVersion(result)}";

    private static string CreateSourceImageVersion(CriticalResult result)
    {
        var provenance = string.Join('|', result.SourcePageNumber?.ToString() ?? string.Empty, result.SourceImagePath ?? string.Empty, result.SourceImageCropJson ?? string.Empty);
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(provenance));
        return Convert.ToHexStringLower(hash[..8]);
    }
}