using System.Collections.Frozen;
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;

namespace NeonSprawl.Server.Tests.Game.Crafting;

public class RecipeDefinitionCatalogLoaderTests
{
    private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;

    private static readonly FrozenSet<string> KnownSkillIds = FrozenSet.ToFrozenSet(
        ["salvage", "refine", "intrusion"],
        StringComparer.Ordinal);

    private const string ValidPrototypeCatalogJson =
        """
        {
          "schemaVersion": 1,
          "recipes": [
            {
              "id": "refine_scrap_standard",
              "displayName": "Refine Scrap (Standard)",
              "recipeKind": "process",
              "requiredSkillId": "refine",
              "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
              "outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
            },
            {
              "id": "refine_scrap_efficient",
              "displayName": "Refine Scrap (Efficient)",
              "recipeKind": "process",
              "requiredSkillId": "refine",
              "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }],
              "outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }]
            },
            {
              "id": "make_field_stim_mk0",
              "displayName": "Make Field Stim Mk0",
              "recipeKind": "make",
              "requiredSkillId": "refine",
              "inputs": [
                { "itemId": "refined_plate_stock", "quantity": 2 },
                { "itemId": "scrap_metal_bulk", "quantity": 1 }
              ],
              "outputs": [{ "itemId": "field_stim_mk0", "quantity": 1 }]
            },
            {
              "id": "make_field_stim_batch",
              "displayName": "Make Field Stim (Batch)",
              "recipeKind": "make",
              "requiredSkillId": "refine",
              "inputs": [
                { "itemId": "refined_plate_stock", "quantity": 5 },
                { "itemId": "scrap_metal_bulk", "quantity": 3 }
              ],
              "outputs": [{ "itemId": "field_stim_mk0", "quantity": 3 }]
            },
            {
              "id": "make_prototype_armor",
              "displayName": "Make Prototype Armor Shell",
              "recipeKind": "make",
              "requiredSkillId": "refine",
              "inputs": [
                { "itemId": "refined_plate_stock", "quantity": 8 },
                { "itemId": "scrap_metal_bulk", "quantity": 5 }
              ],
              "outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }]
            },
            {
              "id": "make_armor_quick",
              "displayName": "Make Armor (Quick)",
              "recipeKind": "make",
              "requiredSkillId": "refine",
              "inputs": [
                { "itemId": "refined_plate_stock", "quantity": 4 },
                { "itemId": "scrap_metal_bulk", "quantity": 3 }
              ],
              "outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }]
            },
            {
              "id": "make_survey_drone_kit",
              "displayName": "Make Survey Drone Kit",
              "recipeKind": "make",
              "requiredSkillId": "refine",
              "inputs": [
                { "itemId": "refined_plate_stock", "quantity": 3 },
                { "itemId": "scrap_metal_bulk", "quantity": 2 }
              ],
              "outputs": [{ "itemId": "survey_drone_kit", "quantity": 1 }]
            },
            {
              "id": "make_contract_token",
              "displayName": "Make Contract Handoff Token",
              "recipeKind": "make",
              "requiredSkillId": "refine",
              "inputs": [
                { "itemId": "refined_plate_stock", "quantity": 2 },
                { "itemId": "scrap_metal_bulk", "quantity": 1 }
              ],
              "outputs": [{ "itemId": "contract_handoff_token", "quantity": 1 }]
            }
          ]
        }
        """;

    private static (string Root, string RecipesDir, string DefSchemaPath, string IoSchemaPath) CreateTempContentLayout()
    {
        var root = Directory.CreateTempSubdirectory("neon-sprawl-recipecat-");
        var recipesDir = Path.Combine(root.FullName, "content", "recipes");
        var schemaDir = Path.Combine(root.FullName, "content", "schemas");
        Directory.CreateDirectory(recipesDir);
        Directory.CreateDirectory(schemaDir);
        var defSchemaPath = Path.Combine(schemaDir, "recipe-def.schema.json");
        var ioSchemaPath = Path.Combine(schemaDir, "recipe-io-row.schema.json");
        File.Copy(RecipeCatalogTestPaths.DiscoverRepoRecipeDefSchemaPath(), defSchemaPath, overwrite: true);
        File.Copy(RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath(), ioSchemaPath, overwrite: true);
        return (root.FullName, recipesDir, defSchemaPath, ioSchemaPath);
    }

    private static void WriteCatalog(string recipesDir, string catalogJson) =>
        File.WriteAllText(Path.Combine(recipesDir, "prototype_recipes.json"), catalogJson, Encoding.UTF8);

    private static JsonObject GetRecipeRow(JsonObject catalogRoot, string recipeId)
    {
        var recipes = catalogRoot["recipes"] as JsonArray
                      ?? throw new InvalidOperationException("expected recipes array");
        foreach (var node in recipes)
        {
            if (node is JsonObject row && row["id"]?.GetValue<string>() == recipeId)
                return row;
        }

        throw new InvalidOperationException($"recipe id not found: {recipeId}");
    }

    private static RecipeDefinitionCatalog LoadCatalog(
        string recipesDir,
        string defSchemaPath,
        string ioSchemaPath) =>
        RecipeDefinitionCatalogLoader.Load(
            recipesDir,
            defSchemaPath,
            ioSchemaPath,
            KnownItemIds,
            KnownSkillIds,
            NullLogger.Instance);

    [Fact]
    public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        WriteCatalog(recipesDir, ValidPrototypeCatalogJson);
        // Act
        var catalog = LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath);
        // Assert
        Assert.Equal(8, catalog.DistinctRecipeCount);
        Assert.Equal(1, catalog.CatalogJsonFileCount);
        Assert.True(catalog.TryGetRecipe("refine_scrap_standard", out var row));
        Assert.NotNull(row);
        Assert.Equal("process", row!.RecipeKind);
        Assert.Single(row.Inputs);
        Assert.Equal("scrap_metal_bulk", row.Inputs[0].ItemId);
        Assert.Equal(5, row.Inputs[0].Quantity);
    }

    [Fact]
    public void Load_ShouldThrow_WhenRecipesIsNotArray()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        File.WriteAllText(
            Path.Combine(recipesDir, "bad_recipes.json"),
            """{"schemaVersion": 1, "recipes": "nope"}""",
            Encoding.UTF8);
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal);
        Assert.Contains("expected top-level 'recipes' array", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        File.WriteAllText(
            Path.Combine(recipesDir, "bad_recipes.json"),
            """{"schemaVersion": 2, "recipes": []}""",
            Encoding.UTF8);
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenRowViolatesSchema()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        const string bad = """
                  {
                    "schemaVersion": 1,
                    "recipes": [
                      {
                        "id": "refine_scrap_standard",
                        "displayName": "",
                        "recipeKind": "process",
                        "requiredSkillId": "refine",
                        "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
                        "outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
                      }
                    ]
                  }
                  """;
        File.WriteAllText(Path.Combine(recipesDir, "bad_recipes.json"), bad, Encoding.UTF8);
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal);
        Assert.Contains("Recipe catalog validation failed", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        const string singleRecipe = """
                         {
                           "schemaVersion": 1,
                           "recipes": [
                             {
                               "id": "refine_scrap_standard",
                               "displayName": "Refine Scrap (Standard)",
                               "recipeKind": "process",
                               "requiredSkillId": "refine",
                               "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
                               "outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
                             }
                           ]
                         }
                         """;
        File.WriteAllText(Path.Combine(recipesDir, "a_recipes.json"), singleRecipe, Encoding.UTF8);
        File.WriteAllText(Path.Combine(recipesDir, "b_recipes.json"), singleRecipe, Encoding.UTF8);
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("duplicate recipe id", ioe.Message, StringComparison.Ordinal);
        Assert.Contains("refine_scrap_standard", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenSlice3IdsIncomplete()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        const string twoOnly = """
                      {
                        "schemaVersion": 1,
                        "recipes": [
                          {
                            "id": "refine_scrap_standard",
                            "displayName": "Refine Scrap (Standard)",
                            "recipeKind": "process",
                            "requiredSkillId": "refine",
                            "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
                            "outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
                          },
                          {
                            "id": "refine_scrap_efficient",
                            "displayName": "Refine Scrap (Efficient)",
                            "recipeKind": "process",
                            "requiredSkillId": "refine",
                            "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }],
                            "outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }]
                          }
                        ]
                      }
                      """;
        File.WriteAllText(Path.Combine(recipesDir, "partial_recipes.json"), twoOnly, Encoding.UTF8);
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("prototype Slice 3 expects exactly recipe ids", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenMissingMakeRecipeKind()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
                   ?? throw new InvalidOperationException("expected object root");
        var recipes = root["recipes"] as JsonArray ?? throw new InvalidOperationException("expected recipes array");
        foreach (var node in recipes)
        {
            if (node is JsonObject row && row["recipeKind"]?.GetValue<string>() == "make")
                row["recipeKind"] = "process";
        }

        WriteCatalog(recipesDir, root.ToJsonString());
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("requires at least one process and one make recipe", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenRequiredSkillIdIsNotRefine()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
                   ?? throw new InvalidOperationException("expected object root");
        GetRecipeRow(root, "make_field_stim_mk0")["requiredSkillId"] = "salvage";
        WriteCatalog(recipesDir, root.ToJsonString());
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("make_field_stim_mk0", ioe.Message, StringComparison.Ordinal);
        Assert.Contains("requiredSkillId must be 'refine'", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenUnknownItemIdInInputs()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
                   ?? throw new InvalidOperationException("expected object root");
        var row = GetRecipeRow(root, "refine_scrap_standard");
        var inputs = row["inputs"] as JsonArray ?? throw new InvalidOperationException("expected inputs array");
        inputs[0] = JsonNode.Parse("""{"itemId": "unknown_item_xyz", "quantity": 5}""");
        WriteCatalog(recipesDir, root.ToJsonString());
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("unknown_item_xyz", ioe.Message, StringComparison.Ordinal);
        Assert.Contains("is not in item catalogs", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenUnknownItemIdInOutputs()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
                   ?? throw new InvalidOperationException("expected object root");
        var row = GetRecipeRow(root, "refine_scrap_standard");
        var outputs = row["outputs"] as JsonArray ?? throw new InvalidOperationException("expected outputs array");
        outputs[0] = JsonNode.Parse("""{"itemId": "unknown_item_xyz", "quantity": 1}""");
        WriteCatalog(recipesDir, root.ToJsonString());
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("unknown_item_xyz", ioe.Message, StringComparison.Ordinal);
        Assert.Contains("outputs[0]", ioe.Message, StringComparison.Ordinal);
        Assert.Contains("is not in item catalogs", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenNoRecipeCatalogFiles()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("no *_recipes.json files", ioe.Message, StringComparison.Ordinal);
        Assert.Contains(recipesDir, ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenJsonIsInvalid()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        File.WriteAllText(Path.Combine(recipesDir, "bad_recipes.json"), "{ not json", Encoding.UTF8);
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal);
        Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenUnknownRequiredSkillId()
    {
        // Arrange
        var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
        var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
                   ?? throw new InvalidOperationException("expected object root");
        GetRecipeRow(root, "refine_scrap_standard")["requiredSkillId"] = "unknown_skill_xyz";
        WriteCatalog(recipesDir, root.ToJsonString());
        // Act
        var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("unknown_skill_xyz", ioe.Message, StringComparison.Ordinal);
        Assert.Contains("is not a known SkillDef id", ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenRecipesDirectoryMissing()
    {
        // Arrange
        var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-recipes-" + Guid.NewGuid().ToString("n"));
        var defSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeDefSchemaPath();
        var ioSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath();
        // Act
        var ex = Record.Exception(() =>
            RecipeDefinitionCatalogLoader.Load(
                missingDir,
                defSchemaPath,
                ioSchemaPath,
                KnownItemIds,
                KnownSkillIds,
                NullLogger.Instance));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
        Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void Load_ShouldThrow_WhenSchemaFileMissing()
    {
        // Arrange
        var (_, recipesDir, _, _) = CreateTempContentLayout();
        var missingSchema = Path.Combine(recipesDir, "missing-recipe-def.schema.json");
        var ioSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath();
        // Act
        var ex = Record.Exception(() =>
            RecipeDefinitionCatalogLoader.Load(
                recipesDir,
                missingSchema,
                ioSchemaPath,
                KnownItemIds,
                KnownSkillIds,
                NullLogger.Instance));
        // Assert
        var ioe = Assert.IsType<InvalidOperationException>(ex);
        Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
        Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
    {
        // Arrange
        await using var factory = new InMemoryWebApplicationFactory();
        using var client = factory.CreateClient();
        // Act
        var response = await client.GetAsync("/health");
        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var catalog = factory.Services.GetRequiredService<RecipeDefinitionCatalog>();
        Assert.Equal(8, catalog.DistinctRecipeCount);
        Assert.True(catalog.TryGetRecipe("make_field_stim_mk0", out var stim));
        Assert.Equal("make", stim!.RecipeKind);
        Assert.Equal(2, stim.Inputs.Count);
    }

    [Fact]
    public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
    {
        // Arrange
        var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-recipes-" + Guid.NewGuid().ToString("n"));
        Directory.CreateDirectory(badDir);
        try
        {
            // Act
            var ex = Record.Exception(() =>
            {
                using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
                    b.UseSetting("Content:RecipesDirectory", badDir));
                factory.CreateClient();
            });
            // Assert
            Assert.NotNull(ex);
            Assert.Contains("Recipe catalog validation failed", ex.ToString(), StringComparison.Ordinal);
        }
        finally
        {
            if (Directory.Exists(badDir))
                Directory.Delete(badDir);
        }
    }
}
