using System;
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
namespace Cthangover.Validator
{
///
/// Phase: Contracts. Walks every mod's JSON entities the same way the
/// runtime resolver does — every .json file is opened and routed by
/// its top-level "Kind", with ${include} expansion applied —
/// and records declarations and references in the shared graph. Files
/// without a kind (manifests, collections, live2d models, scene
/// definitions, patches) are not entity data and are skipped; scene and
/// interactive definitions plus raw asset files keep their directory-based
/// collection. It also validates manifest ui/main_menu
/// scene_path entries against the owning mod's files and turns
/// ui[].scene into a scene reference.
///
/// Extraction is intentionally schema-light (raw
/// plus case-insensitive reads) so it keeps working when DTOs gain fields
/// and so it can run on the background thread without touching Godot types
/// that some DTOs embed.
///
public class JsonEntityStrategy : IValidationStrategy
{
private static readonly HashSet EffectExtensions = new(StringComparer.OrdinalIgnoreCase) { ".tscn", ".scn" };
public string Id => "json_entities";
public ValidationPhase Phase => ValidationPhase.Contracts;
public int Order => 100;
public ValidationFeature Feature => ValidationFeature.Json;
/// Walks every mod's JSON entities, scene definitions, interactives and texture assets, recording declarations and references into the fact graph.
public void Validate(ValidationContext context)
{
foreach (var mod in context.Mods)
{
context.ThrowIfCancellationRequested();
var provider = mod.FileProvider;
if (provider == null)
continue;
CollectEntities(context, mod, provider);
CollectSceneDefinitions(context, mod, provider);
CollectEnvelopes(context, mod, provider, "interactives", true, ValidationKind.Interactive);
var textureExtensions = ModConfig.Instance.GetTextureExtensionSet();
CollectAssets(context, mod, provider, "backgrounds", ValidationKind.Background, textureExtensions);
CollectAssets(context, mod, provider, "avatars", ValidationKind.Avatar, textureExtensions);
var audioExtensions = new HashSet(ModConfig.Instance.AudioExtensions, StringComparer.OrdinalIgnoreCase);
CollectAssets(context, mod, provider, "music", ValidationKind.Music, audioExtensions);
CollectAssets(context, mod, provider, "sounds", ValidationKind.Sound, audioExtensions);
CollectAssets(context, mod, provider, "effects", ValidationKind.Effect, EffectExtensions);
ValidateManifestEntries(context, mod, provider);
}
}
///
/// Scans every JSON file of the mod and routes it by its declared
/// "Kind", mirroring the runtime EntityResolver. The kind
/// resolves to an that records the
/// declarations, declared keys and references for that entity type — so
/// an action can no longer be mistaken for a character just because both
/// lived in the same directory, and a new kind is a new strategy class
/// instead of a new switch case.
///
private static void CollectEntities(ValidationContext context, IModInfo mod, IModFileProvider provider)
{
foreach (var file in ModFileReader.ListFiles(provider, string.Empty, true))
{
context.ThrowIfCancellationRequested();
if (!file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
continue;
// The reserved collections/ folder holds scheme documents whose
// "kind" (game/component/toolset) is not an entity discriminator.
if (file.StartsWith(ModCollectionStore.ProviderDirectory + "/", StringComparison.OrdinalIgnoreCase))
continue;
var document = ReadDocument(context, mod, file);
if (document == null)
continue;
using (document)
{
var kind = JsonReadHelper.GetString(document.RootElement, "Kind", "kind");
if (string.IsNullOrWhiteSpace(kind))
continue;
var envelope = JsonReadHelper.GetProperty(document.RootElement, "Items");
if (!JsonReadHelper.IsArray(envelope))
continue;
var strategy = EntityKindStrategyRegistry.Resolve(kind);
if (strategy == null)
{
context.AddWarning("UNKNOWN_ENTITY_KIND",
$"unknown entity kind '{kind}'",
mod.Id, file, 0);
continue;
}
strategy.Collect(context, mod, file, envelope.Value);
}
}
}
private static void CollectSceneDefinitions(ValidationContext context, IModInfo mod, IModFileProvider provider)
{
foreach (var file in ModFileReader.ListFiles(provider, "scenes", true))
{
context.ThrowIfCancellationRequested();
if (!file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
continue;
var document = ReadDocument(context, mod, file);
if (document == null)
continue;
using (document)
{
var name = JsonReadHelper.GetString(document.RootElement, "name", "Name");
if (string.IsNullOrWhiteSpace(name))
{
context.AddWarning("SCHEMA_MISSING_FIELD", "scene definition has no 'name'", mod.Id, file);
continue;
}
context.Graph.AddDeclaration(ValidationKind.Scene, name, mod.Id, file, 0);
CollectBackgroundReferences(context, mod, document.RootElement, file);
}
}
}
private static void CollectEnvelopes(
ValidationContext context,
IModInfo mod,
IModFileProvider provider,
string directory,
bool recursive,
string kind)
{
var documents = new List();
var items = new List();
try
{
OpenEnvelopes(context, mod, provider, directory, recursive, documents, items);
foreach (var item in items)
{
var id = JsonReadHelper.GetString(item, "Id", "ID", "id");
context.Graph.AddDeclaration(kind, id, mod.Id, directory, 0);
}
}
finally
{
DisposeAll(documents);
}
}
private static void CollectAssets(
ValidationContext context,
IModInfo mod,
IModFileProvider provider,
string directory,
string kind,
HashSet extensions)
{
foreach (var file in ModFileReader.ListFiles(provider, directory, true))
{
context.ThrowIfCancellationRequested();
if (!ModFileReader.HasExtension(file, extensions))
continue;
var prefixLength = directory.Length + 1;
var relative = file.Length > prefixLength ? file.Substring(prefixLength) : file;
var dot = relative.LastIndexOf('.');
if (dot <= 0)
continue;
context.Graph.AddDeclaration(kind, relative.Substring(0, dot), mod.Id, file, 0);
}
}
private static void ValidateManifestEntries(ValidationContext context, IModInfo mod, IModFileProvider provider)
{
var manifest = (mod as ModInfo)?.Manifest;
if (manifest == null)
return;
if (manifest.Ui != null)
{
foreach (var entry in manifest.Ui)
{
if (entry == null)
continue;
if (!string.IsNullOrWhiteSpace(entry.ScenePath) && !provider.FileExists(entry.ScenePath))
context.AddError("REF_NOT_FOUND",
$"ui scene_path '{entry.ScenePath}' not found in mod '{mod.Id}'",
mod.Id, "manifest.json", 0, "fix scene_path or add the scene file");
if (!string.IsNullOrWhiteSpace(entry.Scene)
&& !string.Equals(entry.Scene, "shell", StringComparison.OrdinalIgnoreCase))
context.Graph.AddReference(ValidationKind.Scene, entry.Scene, mod.Id, "manifest.json", 0);
}
}
if (manifest.MainMenu != null)
{
foreach (var entry in manifest.MainMenu)
{
if (entry == null || string.IsNullOrWhiteSpace(entry.ScenePath))
continue;
if (!provider.FileExists(entry.ScenePath))
context.AddError("REF_NOT_FOUND",
$"main_menu scene_path '{entry.ScenePath}' not found in mod '{mod.Id}'",
mod.Id, "manifest.json", 0, "fix scene_path or add the scene file");
}
}
}
private static void OpenEnvelopes(
ValidationContext context,
IModInfo mod,
IModFileProvider provider,
string directory,
bool recursive,
List documents,
List items)
{
foreach (var file in ModFileReader.ListFiles(provider, directory, recursive))
{
context.ThrowIfCancellationRequested();
if (!file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
continue;
var document = ReadDocument(context, mod, file);
if (document == null)
continue;
var envelope = JsonReadHelper.GetProperty(document.RootElement, "Items");
if (!JsonReadHelper.IsArray(envelope))
{
document.Dispose();
continue;
}
documents.Add(document);
foreach (var item in envelope.Value.EnumerateArray())
items.Add(item);
}
}
private static JsonDocument ReadDocument(ValidationContext context, IModInfo mod, string file)
{
string text;
try
{
text = ModFileReader.ReadResolved(mod.FileProvider, file);
}
catch (Exception ex)
{
context.AddError("JSON_PARSE_ERROR", $"include resolution failed: {ex.Message}", mod.Id, file);
return null;
}
if (text == null)
return null;
try
{
return JsonDocument.Parse(text, JsonOptionsProvider.DocumentOptions);
}
catch (JsonException ex)
{
context.AddError("JSON_PARSE_ERROR", $"invalid JSON: {ex.Message}", mod.Id, file);
return null;
}
}
private static void CollectBackgroundReferences(ValidationContext context, IModInfo mod, JsonElement root, string file)
{
var background = JsonReadHelper.GetProperty(root, "defaultBackground", "DefaultBackground");
if (background == null)
return;
if (background.Value.ValueKind == JsonValueKind.String)
{
context.Graph.AddReference(ValidationKind.Background, background.Value.GetString(), mod.Id, file, 0);
return;
}
if (background.Value.ValueKind != JsonValueKind.Array)
return;
foreach (var entry in background.Value.EnumerateArray())
{
if (entry.ValueKind == JsonValueKind.String)
context.Graph.AddReference(ValidationKind.Background, entry.GetString(), mod.Id, file, 0);
}
}
private static void DisposeAll(List documents)
{
foreach (var document in documents)
document.Dispose();
}
}
}