using System.Linq;

using Microsoft.EntityFrameworkCore;

using RolemasterDb.App.Data;
using RolemasterDb.App.Domain;
using RolemasterDb.App.Features;
using RolemasterDb.ImportTool.Parsing;

namespace RolemasterDb.ImportTool.Tests;

public sealed class StandardCriticalTableParserIntegrationTests
{
    private static readonly string[] ExpectedEnabledSlugs =
    [
        "arcane-aether",
        "arcane-nether",
        "ballistic-shrapnel",
        "brawling",
        "cold",
        "electricity",
        "grapple",
        "heat",
        "impact",
        "krush",
        "large_creature_magic",
        "large_creature_weapon",
        "ma-strikes",
        "ma-sweeps",
        "mana",
        "puncture",
        "slash",
        "subdual",
        "super_large_creature_weapon",
        "tiny",
        "unbalance",
        "void"
    ];

    private static readonly PdfXmlExtractor Extractor = new();
    private static readonly StandardCriticalTableParser StandardParser = new();
    private static readonly StandardOcrBootstrapper StandardOcrBootstrapper = new();
    private static readonly VariantColumnCriticalTableParser VariantColumnParser = new();
    private static readonly GroupedVariantCriticalTableParser GroupedVariantParser = new();

    public static IEnumerable<object[]> EnabledTables() =>
        LoadManifest().Tables
            .Where(item => item.Enabled)
            .OrderBy(item => item.Slug, StringComparer.Ordinal)
            .Select(item => new object[] { item });

    public static IEnumerable<object[]> RepresentativeCells()
    {
        yield return new object[] { "slash", null!, "71-75", "A", "Blow falls on lower leg" };
        yield return new object[] { "puncture", null!, "66", "C", "Strike shatters foe's knee" };
        yield return new object[] { "ballistic-shrapnel", null!, "86-90", "E", "destroy his heart" };
        yield return new object[] { "arcane-aether", null!, "96-99", "E", "smoking pulp" };
        yield return new object[] { "ma-strikes", null!, "96-99", "E", "drives bone into brain" };
        yield return new object[] { "mana", null!, "96-99", "E", "momentarily transformed" };
        yield return new object[] { "mana", null!, "100", "E", "Mana consumes everything" };
        yield return new object[] { "tiny", null!, "100", "E", "Vein and artery severed" };
        yield return new object[] { "void", null!, "96-99", "D", "Foe inhales the void" };
        yield return new object[] { "large_creature_weapon", null!, "01-05", "NORMAL", "Weapon shatters" };
        yield return new object[] { "super_large_creature_weapon", null!, "31-40", "SLAYING", "Boom! Solid without question" };
        yield return new object[] { "large_creature_magic", "large", "251+", "NORMAL", "Foe lowers his eyes within your reach" };
        yield return new object[] { "large_creature_magic", "super_large", "251+", "SLAYING", "Blast goes in through foe's eye" };
    }

    [Fact]
    public void Manifest_enables_the_phase_4_table_set()
    {
        var manifest = LoadManifest();
        var enabledTables = manifest.Tables
            .Where(item => item.Enabled)
            .OrderBy(item => item.Slug, StringComparer.Ordinal)
            .ToList();

        Assert.Equal(ExpectedEnabledSlugs, enabledTables.Select(item => item.Slug));
        Assert.All(enabledTables, entry =>
        {
            Assert.True(
                new[] { "xml", "ocr" }.Contains(entry.ExtractionMethod, StringComparer.Ordinal),
                $"Unexpected extraction method '{entry.ExtractionMethod}' for '{entry.Slug}'.");
            Assert.True(File.Exists(Path.Combine(GetRepositoryRoot(), entry.PdfPath)), $"Missing source PDF for '{entry.Slug}'.");
        });

        Assert.Equal("variant_column", enabledTables.Single(item => item.Slug == "large_creature_weapon").Family);
        Assert.Equal("variant_column", enabledTables.Single(item => item.Slug == "super_large_creature_weapon").Family);
        Assert.Equal("grouped_variant", enabledTables.Single(item => item.Slug == "large_creature_magic").Family);
        Assert.Equal("ocr", enabledTables.Single(item => item.Slug == "void").ExtractionMethod);
    }

    [Theory]
    [MemberData(nameof(EnabledTables))]
    public async Task Enabled_tables_extract_and_parse_successfully(CriticalImportManifestEntry entry)
    {
        var parseResult = await LoadParseResultAsync(entry);
        var expectedGroupCount = Math.Max(parseResult.Table.Groups.Count, 1);

        Assert.True(parseResult.ValidationReport.IsValid, string.Join(Environment.NewLine, parseResult.ValidationReport.Errors));
        Assert.NotEmpty(parseResult.Table.Columns);
        Assert.NotEmpty(parseResult.Table.RollBands);
        Assert.Equal(parseResult.ValidationReport.RowCount * parseResult.Table.Columns.Count * expectedGroupCount, parseResult.ValidationReport.CellCount);
        Assert.Equal(parseResult.ValidationReport.CellCount, parseResult.Table.Results.Count);
    }

    [Theory]
    [MemberData(nameof(RepresentativeCells))]
    public async Task Representative_cells_keep_expected_descriptions(
        string slug,
        string? groupKey,
        string rollBandLabel,
        string columnKey,
        string expectedSnippet)
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, slug, StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var result = parseResult.Table.Results.Single(item =>
            string.Equals(item.GroupKey, groupKey, StringComparison.Ordinal) &&
            string.Equals(item.RollBandLabel, rollBandLabel, StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, columnKey, StringComparison.Ordinal));

        Assert.Contains(expectedSnippet, result.DescriptionText, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Arcane_aether_c_31_has_single_bleed_effect()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "arcane-aether", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var result = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "31-40", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "C", StringComparison.Ordinal));

        var bleedEffects = result.Effects
            .Where(effect => effect.EffectCode == CriticalEffectCodes.BleedPerRound)
            .ToList();

        Assert.Equal("+15H – ∑ – ∫", result.RawAffixText);
        Assert.Single(bleedEffects);
        Assert.Equal(1, bleedEffects.Single().PerRound);
    }

    [Fact]
    public async Task Puncture_91_95_keeps_a_and_b_prose_in_separate_columns()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "puncture", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row91A = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "91-95", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "A", StringComparison.Ordinal));
        var row91B = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "91-95", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "B", StringComparison.Ordinal));

        Assert.Equal("Strike to foe's ear. Foe hears at -50.", row91A.DescriptionText);
        Assert.DoesNotContain("Strike to foe's hip.", row91A.DescriptionText, StringComparison.Ordinal);
        Assert.Contains("Strike to foe's hip.", row91B.DescriptionText, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Slash_boundary_repair_keeps_56_60_a_prose_first()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var result = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "56-60", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "A", StringComparison.Ordinal));

        Assert.StartsWith("You recover from your initial swing", result.RawCellText, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Mana_boundary_repair_keeps_96_99_and_100_cells_separated()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row96E = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "96-99", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "E", StringComparison.Ordinal));
        var row100E = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "100", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "E", StringComparison.Ordinal));

        Assert.Contains("momentarily transformed", row96E.DescriptionText, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("Mana consumes everything", row96E.DescriptionText, StringComparison.OrdinalIgnoreCase);
        Assert.StartsWith("Mana consumes everything.", row100E.DescriptionText, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Mana_symbol_only_affix_lines_do_not_pollute_descriptions()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row100C = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "100", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "C", StringComparison.Ordinal));

        Assert.DoesNotContain('\uF052', row100C.DescriptionText);
        Assert.DoesNotContain('\uF06C', row100C.DescriptionText);
    }

    [Fact]
    public async Task Arcane_aether_first_row_keeps_c_and_d_text_in_separate_columns()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "arcane-aether", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row01C = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "01-05", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "C", StringComparison.Ordinal));
        var row01D = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "01-05", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "D", StringComparison.Ordinal));

        Assert.Equal("Ooooh. That's the way to frighten him", row01C.DescriptionText);
        Assert.Equal("That looked like it hurt. It didn't.", row01D.DescriptionText);
    }

    [Fact]
    public async Task Arcane_aether_31_40_keeps_a_and_b_text_in_separate_columns()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "arcane-aether", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row31A = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "31-40", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "A", StringComparison.Ordinal));
        var row31B = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "31-40", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "B", StringComparison.Ordinal));

        Assert.Equal("Burns cause foe to bring up his guard.", row31A.DescriptionText);
        Assert.Equal("Confused foe brings up his guard. He loses initiative for two rounds.", row31B.DescriptionText);
    }

    [Fact]
    public async Task Arcane_aether_41_50_keeps_d_and_e_text_in_separate_columns()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "arcane-aether", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row41D = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "41-50", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "D", StringComparison.Ordinal));
        var row41E = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "41-50", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "E", StringComparison.Ordinal));

        Assert.Equal("Foe is spun by a strike to his shoulder.", row41D.DescriptionText);
        Assert.Equal("Powerful blast knocks foe back three steps and cause him to drop all objects.", row41E.DescriptionText);
    }

    [Fact]
    public async Task Mana_first_row_keeps_all_five_columns_populated()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);

        Assert.Equal("Lots of fireworks, but little effect.", FindResult(parseResult, "01-05", "A").DescriptionText);
        Assert.Equal("Somewhere a bell tolls.", FindResult(parseResult, "01-05", "B").DescriptionText);
        Assert.Equal("Weak blast.", FindResult(parseResult, "01-05", "C").DescriptionText);
        Assert.Equal("Foe dances around your blast.", FindResult(parseResult, "01-05", "D").DescriptionText);
        Assert.Equal("Foe does damage trying to dodge.", FindResult(parseResult, "01-05", "E").DescriptionText);
    }

    [Fact]
    public async Task Arcane_nether_first_row_keeps_b_column_populated()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "arcane-nether", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);

        Assert.Equal("Glancing blow.", FindResult(parseResult, "01-05", "B").DescriptionText);
    }

    [Fact]
    public async Task Krush_36_45_keeps_a_b_and_c_content_in_separate_columns()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "krush", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);

        Assert.Equal("Bust foe's shin. You have initiative.", FindResult(parseResult, "36-45", "A").DescriptionText);
        Assert.Equal("Blow to foe's left calf. You gain initiative.", FindResult(parseResult, "36-45", "B").DescriptionText);
        Assert.Equal("Catch foe in lower leg. You gain initiative, while foe regains footing.", FindResult(parseResult, "36-45", "C").DescriptionText);
    }

    [Fact]
    public async Task Super_large_creature_weapon_99_100_holy_arms_does_not_capture_previous_row_text()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "super_large_creature_weapon", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row99HolyArms = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "99-100", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "HOLY_ARMS", StringComparison.Ordinal));

        Assert.StartsWith("Strike through foe's heart kills him instantly.", row99HolyArms.DescriptionText, StringComparison.Ordinal);
        Assert.DoesNotContain("all allies get (+10)", row99HolyArms.DescriptionText, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Mana_affix_boundaries_keep_71_75_a_and_b_separate()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row71A = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "71-75", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "A", StringComparison.Ordinal));
        var row71B = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "71-75", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "B", StringComparison.Ordinal));

        Assert.DoesNotContain("+10H -", row71A.RawAffixText, StringComparison.Ordinal);
        Assert.Contains("+10H -", row71B.RawAffixText, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Mana_affix_boundaries_keep_71_75_d_and_e_separate()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row71D = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "71-75", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "D", StringComparison.Ordinal));
        var row71E = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "71-75", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "E", StringComparison.Ordinal));

        Assert.DoesNotContain("+16H - 6", row71D.RawAffixText, StringComparison.Ordinal);
        Assert.Contains("+16H - 6", row71E.RawAffixText, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Mana_affix_boundaries_keep_91_95_b_and_c_separate()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row91B = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "91-95", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "B", StringComparison.Ordinal));
        var row91C = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "91-95", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "C", StringComparison.Ordinal));

        Assert.DoesNotContain("+19H - 9", row91B.RawAffixText, StringComparison.Ordinal);
        Assert.Contains("+19H - 9", row91C.RawAffixText, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Mana_affix_boundaries_keep_86_90_b_and_c_separate()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var row86B = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "86-90", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "B", StringComparison.Ordinal));
        var row86C = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "86-90", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "C", StringComparison.Ordinal));

        Assert.DoesNotContain("+16H - 8", row86B.RawAffixText, StringComparison.Ordinal);
        Assert.Contains("+16H - 8", row86C.RawAffixText, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Grouped_magic_table_keeps_large_and_super_large_groups_distinct()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "large_creature_magic", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);

        Assert.Equal(["large", "super_large"], parseResult.Table.Groups.Select(item => item.GroupKey));

        var largeNormal = parseResult.Table.Results.Single(item =>
            string.Equals(item.GroupKey, "large", StringComparison.Ordinal) &&
            string.Equals(item.RollBandLabel, "251+", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "NORMAL", StringComparison.Ordinal));
        var superSlaying = parseResult.Table.Results.Single(item =>
            string.Equals(item.GroupKey, "super_large", StringComparison.Ordinal) &&
            string.Equals(item.RollBandLabel, "251+", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "SLAYING", StringComparison.Ordinal));

        Assert.DoesNotContain("Blast goes in through foe's eye", largeNormal.DescriptionText, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("Blast goes in through foe's eye", superSlaying.DescriptionText, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Slash_branch_cells_split_base_text_from_conditional_affix_branches()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var result = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "36-45", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "B", StringComparison.Ordinal));

        Assert.Equal("Strike foe in shin. If he doesn't have greaves, you slash open foe's shin.", result.DescriptionText);
        Assert.Null(result.RawAffixText);
        Assert.DoesNotContain("with leg greaves:", result.RawCellText, StringComparison.OrdinalIgnoreCase);
        Assert.Equal(2, result.Branches.Count);

        var withGreaves = result.Branches.Single(item => string.Equals(item.ConditionText, "with leg greaves", StringComparison.OrdinalIgnoreCase));
        var withoutGreaves = result.Branches.Single(item => string.Equals(item.ConditionText, "w/o leg greaves", StringComparison.OrdinalIgnoreCase));

        Assert.Equal("with_leg_greaves", withGreaves.ConditionKey);
        Assert.Equal("+2H – π", withGreaves.RawAffixText);
        Assert.Equal(string.Empty, withGreaves.DescriptionText);
        Assert.Equal("without_leg_greaves", withoutGreaves.ConditionKey);
        Assert.Equal("+2H – ∫", withoutGreaves.RawAffixText);
    }

    [Fact]
    public async Task Impact_branch_cells_keep_prose_branch_text_separate_from_affix_branch_text()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "impact", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var result = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "86-90", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "D", StringComparison.Ordinal));

        Assert.Equal(
            "Onslaught to foe's midsection. Organs are damaged and foe throws up blood. Foe's abdomen is seriously damaged. He falls and should not be moved.",
            result.DescriptionText);
        Assert.Null(result.RawAffixText);
        Assert.Equal(2, result.Branches.Count);

        var withArmor = result.Branches.Single(item => string.Equals(item.ConditionText, "with abdominal armor", StringComparison.OrdinalIgnoreCase));
        var withoutArmor = result.Branches.Single(item => string.Equals(item.ConditionText, "w/o abdominal armor", StringComparison.OrdinalIgnoreCase));

        Assert.Equal("12∑", withArmor.RawAffixText);
        Assert.Equal(string.Empty, withArmor.DescriptionText);
        Assert.Null(withoutArmor.RawAffixText);
        Assert.Equal("dies in 6 rounds", withoutArmor.DescriptionText);
    }

    [Fact]
    public async Task Slash_base_affixes_are_normalized_into_effects()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var result = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "51-55", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "D", StringComparison.Ordinal));

        Assert.Equal("+5H – π – 3∫ – (-15)", result.RawAffixText);
        Assert.Collection(
            result.Effects,
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.DirectHits, effect.EffectCode);
                Assert.Equal(5, effect.ValueInteger);
            },
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.MustParryRounds, effect.EffectCode);
                Assert.Equal(1, effect.DurationRounds);
            },
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.BleedPerRound, effect.EffectCode);
                Assert.Equal(3, effect.PerRound);
            },
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.FoePenalty, effect.EffectCode);
                Assert.Equal(-15, effect.Modifier);
            });
    }

    [Fact]
    public async Task Slash_branch_affixes_are_normalized_into_effects()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var result = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "36-45", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "B", StringComparison.Ordinal));

        var withGreaves = result.Branches.Single(item => string.Equals(item.ConditionKey, "with_leg_greaves", StringComparison.Ordinal));
        var withoutGreaves = result.Branches.Single(item => string.Equals(item.ConditionKey, "without_leg_greaves", StringComparison.Ordinal));

        Assert.Collection(
            withGreaves.Effects,
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.DirectHits, effect.EffectCode);
                Assert.Equal(2, effect.ValueInteger);
            },
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.MustParryRounds, effect.EffectCode);
                Assert.Equal(1, effect.DurationRounds);
            });

        Assert.Collection(
            withoutGreaves.Effects,
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.DirectHits, effect.EffectCode);
                Assert.Equal(2, effect.ValueInteger);
            },
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.BleedPerRound, effect.EffectCode);
                Assert.Equal(1, effect.PerRound);
            });
    }

    [Fact]
    public async Task Mana_affixes_use_footer_legend_for_stun_and_powerpoint_modification()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var result = parseResult.Table.Results.Single(item =>
            item.GroupKey is null &&
            string.Equals(item.RollBandLabel, "21-35", StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, "C", StringComparison.Ordinal));

        Assert.Equal("+8H -  - +(2d10-18)P", result.RawAffixText);
        Assert.Collection(
            result.Effects,
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.DirectHits, effect.EffectCode);
                Assert.Equal(8, effect.ValueInteger);
            },
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.StunnedRounds, effect.EffectCode);
                Assert.Equal(1, effect.DurationRounds);
            },
            effect =>
            {
                Assert.Equal(CriticalEffectCodes.PowerPointModifier, effect.EffectCode);
                Assert.Equal("+2d10-18", effect.ValueExpression);
            });
    }

    [Fact]
    public async Task Loader_upgrades_existing_sqlite_and_persists_branch_rows_and_effects()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var databasePath = CreateTemporaryDatabaseCopy();
        var loader = new CriticalImportLoader(databasePath);

        await loader.LoadAsync(parseResult.Table);

        await using var dbContext = CreateDbContext(databasePath);
        var result = await dbContext.CriticalResults
            .Include(item => item.CriticalTable)
            .Include(item => item.CriticalColumn)
            .Include(item => item.CriticalRollBand)
            .Include(item => item.Branches)
                .ThenInclude(item => item.Effects)
            .SingleAsync(item =>
                item.CriticalTable.Slug == "slash" &&
                item.CriticalColumn.ColumnKey == "B" &&
                item.CriticalRollBand.Label == "36-45");

        Assert.DoesNotContain("with leg greaves:", result.RawCellText, StringComparison.OrdinalIgnoreCase);
        Assert.Equal(2, result.Branches.Count);
        Assert.Contains(result.Branches, item => item.ConditionKey == "with_leg_greaves" && item.RawAffixText == "+2H – π");
        Assert.Contains(result.Branches, item => item.ConditionKey == "without_leg_greaves" && item.RawAffixText == "+2H – ∫");
        Assert.Contains(result.Branches.SelectMany(item => item.Effects), item => item.EffectCode == CriticalEffectCodes.MustParryRounds);
        Assert.Contains(result.Branches.SelectMany(item => item.Effects), item => item.EffectCode == CriticalEffectCodes.BleedPerRound);
    }

    [Fact]
    public async Task Loader_persists_puncture_b_91_95_description_text()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "puncture", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var databasePath = CreateTemporaryDatabaseCopy();
        var loader = new CriticalImportLoader(databasePath);

        await loader.LoadAsync(parseResult.Table);

        await using var dbContext = CreateDbContext(databasePath);
        var result = await dbContext.CriticalResults
            .Include(item => item.CriticalTable)
            .Include(item => item.CriticalColumn)
            .Include(item => item.CriticalRollBand)
            .SingleAsync(item =>
                item.CriticalTable.Slug == "puncture" &&
                item.CriticalColumn.ColumnKey == "B" &&
                item.CriticalRollBand.Label == "91-95");

        Assert.Contains("Strike to foe's hip.", result.DescriptionText, StringComparison.Ordinal);
        Assert.StartsWith("Strike to foe's hip.", result.RawCellText, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Loader_persists_void_table_from_fixture()
    {
        var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "void", StringComparison.Ordinal));
        var parseResult = await LoadParseResultAsync(entry);
        var databasePath = CreateTemporaryDatabaseCopy();
        var loader = new CriticalImportLoader(databasePath);

        await loader.LoadAsync(parseResult.Table);

        await using var dbContext = CreateDbContext(databasePath);
        var results = await dbContext.CriticalResults
            .Include(item => item.CriticalTable)
            .Where(item => item.CriticalTable.Slug == "void")
            .CountAsync();

        Assert.Equal(95, results);
    }

    [Fact]
    public async Task Lookup_service_returns_effects_for_results_and_branches()
    {
        var slashEntry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
        var manaEntry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
        var slashParseResult = await LoadParseResultAsync(slashEntry);
        var manaParseResult = await LoadParseResultAsync(manaEntry);
        var databasePath = CreateTemporaryDatabaseCopy();
        var loader = new CriticalImportLoader(databasePath);

        await loader.LoadAsync(slashParseResult.Table);
        await loader.LoadAsync(manaParseResult.Table);

        var factory = CreateDbContextFactory(databasePath);
        var lookupService = new LookupService(factory);

        var slashResponse = await lookupService.LookupCriticalAsync(new CriticalLookupRequest("slash", "B", 40, null));
        var manaResponse = await lookupService.LookupCriticalAsync(new CriticalLookupRequest("mana", "C", 30, null));

        Assert.NotNull(slashResponse);
        Assert.NotNull(manaResponse);
        Assert.Contains(slashResponse!.Branches, branch => branch.ConditionKey == "with_leg_greaves" && branch.Effects.Any(effect => effect.EffectCode == CriticalEffectCodes.MustParryRounds));
        Assert.Contains(slashResponse.Branches, branch => branch.ConditionKey == "without_leg_greaves" && branch.Effects.Any(effect => effect.EffectCode == CriticalEffectCodes.BleedPerRound));
        Assert.Contains(manaResponse!.Effects, effect => effect.EffectCode == CriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "+2d10-18");
    }

    private static async Task<CriticalTableParseResult> LoadParseResultAsync(CriticalImportManifestEntry entry)
    {
        if (string.Equals(entry.ExtractionMethod, "ocr", StringComparison.OrdinalIgnoreCase))
        {
            var tsvContent = await File.ReadAllTextAsync(Path.Combine(GetRepositoryRoot(), "src", "RolemasterDb.ImportTool.Tests", "Fixtures", "Void", "source.ocr.tsv"));
            var source = new ExtractedCriticalSource(
                "ocr",
                "Imported from PDF OCR extraction.",
                SourceRenderProfile.OcrPixels(PdfXmlExtractor.ScaledRenderDpi),
                [new ParsedPdfPageGeometry(1, 3600, 5070)],
                OcrCriticalSourceExtractor.ParseTsv(tsvContent));

            return entry.Family switch
            {
                "standard" => StandardParser.Parse(entry, source, StandardOcrBootstrapper.Bootstrap(source, StandardTableAxisTemplateCatalog.Resolve(entry.AxisTemplateSlug))),
                "variant_column" => VariantColumnParser.Parse(entry, source),
                "grouped_variant" => GroupedVariantParser.Parse(entry, source),
                _ => throw new InvalidOperationException($"Unsupported manifest family '{entry.Family}'.")
            };
        }

        var xmlPath = Path.Combine(GetArtifactCacheRoot(), $"{entry.Slug}.xml");

        if (!File.Exists(xmlPath))
        {
            await Extractor.ExtractAsync(Path.Combine(GetRepositoryRoot(), entry.PdfPath), xmlPath);
        }

        var xmlContent = await File.ReadAllTextAsync(xmlPath);
        return entry.Family switch
        {
            "standard" => StandardParser.Parse(entry, xmlContent),
            "variant_column" => VariantColumnParser.Parse(entry, xmlContent),
            "grouped_variant" => GroupedVariantParser.Parse(entry, xmlContent),
            _ => throw new InvalidOperationException($"Unsupported manifest family '{entry.Family}'.")
        };
    }

    private static CriticalImportManifest LoadManifest() =>
        new CriticalImportManifestLoader().Load(Path.Combine(GetRepositoryRoot(), "sources", "critical-import-manifest.json"));

    private static ParsedCriticalResult FindResult(
        CriticalTableParseResult parseResult,
        string rollBandLabel,
        string columnKey,
        string? groupKey = null) =>
        parseResult.Table.Results.Single(item =>
            string.Equals(item.GroupKey, groupKey, StringComparison.Ordinal) &&
            string.Equals(item.RollBandLabel, rollBandLabel, StringComparison.Ordinal) &&
            string.Equals(item.ColumnKey, columnKey, StringComparison.Ordinal));

    private static string GetArtifactCacheRoot()
    {
        var cacheRoot = Path.Combine(Path.GetTempPath(), "RolemasterDb.ImportTool.Tests");
        Directory.CreateDirectory(cacheRoot);
        return cacheRoot;
    }

    private static RolemasterDbContext CreateDbContext(string databasePath)
    {
        var options = new DbContextOptionsBuilder<RolemasterDbContext>()
            .UseSqlite($"Data Source={databasePath}")
            .Options;

        return new RolemasterDbContext(options);
    }

    private static IDbContextFactory<RolemasterDbContext> CreateDbContextFactory(string databasePath)
    {
        var options = new DbContextOptionsBuilder<RolemasterDbContext>()
            .UseSqlite($"Data Source={databasePath}")
            .Options;

        return new TestRolemasterDbContextFactory(options);
    }

    private static string CreateTemporaryDatabaseCopy()
    {
        var databasePath = Path.Combine(GetArtifactCacheRoot(), $"rolemaster-{Guid.NewGuid():N}.db");
        File.Copy(Path.Combine(GetRepositoryRoot(), "src", "RolemasterDb.App", "rolemaster.db"), databasePath, true);
        UpgradeDatabase(databasePath).GetAwaiter().GetResult();
        return databasePath;
    }

    private static async Task UpgradeDatabase(string databasePath)
    {
        await using var dbContext = CreateDbContext(databasePath);
        await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext);
    }

    private static string GetRepositoryRoot() => TestRepositoryPaths.GetRepositoryRoot();
}
