// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0

using System.IO;
using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Godot.Extensions;
using FileAccess = Godot.FileAccess;

namespace GFramework.Godot.Config;

/// <summary>
///     为 Godot 运行时提供 YAML 配置加载适配层。
///     编辑器态优先直接把项目目录交给 <see cref="YamlConfigLoader" />，
///     导出态则把显式声明的 YAML 与 schema 文本同步到运行时缓存目录后再加载。
/// </summary>
public sealed class GodotYamlConfigLoader : IConfigLoader
{
    private const string HotReloadUnavailableMessage =
        "Hot reload is only available when the source root can be accessed as a normal filesystem directory.";

    private readonly GodotYamlConfigEnvironment _environment;
    private readonly YamlConfigLoader _loader;
    private readonly GodotYamlConfigLoaderOptions _options;

    /// <summary>
    ///     使用指定选项创建一个 Godot YAML 配置加载器。
    /// </summary>
    /// <param name="options">加载器初始化选项。</param>
    /// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为 <see langword="null" /> 时抛出。</exception>
    /// <exception cref="ArgumentException">
    ///     当 <see cref="GodotYamlConfigLoaderOptions.SourceRootPath" /> 或
    ///     <see cref="GodotYamlConfigLoaderOptions.RuntimeCacheRootPath" /> 为空白字符串时抛出。
    /// </exception>
    /// <exception cref="InvalidOperationException">
    ///     当 Godot 特殊路径无法被全局化为非空绝对路径时抛出。
    /// </exception>
    /// <remarks>
    ///     构造完成后，加载器会根据当前环境决定直接读取 <see cref="SourceRootPath" />，还是先同步到
    ///     <see cref="RuntimeCacheRootPath" /> 再交给底层 <see cref="YamlConfigLoader" />。
    ///     只有源根目录可直接作为普通文件系统目录访问时，<see cref="CanEnableHotReload" /> 才会返回
    ///     <see langword="true" />。
    /// </remarks>
    public GodotYamlConfigLoader(GodotYamlConfigLoaderOptions options)
        : this(options, GodotYamlConfigEnvironment.Default)
    {
    }

    /// <summary>
    ///     使用指定选项和宿主环境抽象创建一个 Godot YAML 配置加载器。
    /// </summary>
    /// <param name="options">加载器初始化选项。</param>
    /// <param name="environment">
    ///     封装编辑器探测、Godot 路径全局化、目录枚举与文件读取行为的宿主环境抽象。
    /// </param>
    /// <exception cref="ArgumentNullException">
    ///     <paramref name="options" /> 或 <paramref name="environment" /> 为 <see langword="null" /> 时抛出。
    /// </exception>
    /// <exception cref="ArgumentException">
    ///     <see cref="GodotYamlConfigLoaderOptions.SourceRootPath" /> 或
    ///     <see cref="GodotYamlConfigLoaderOptions.RuntimeCacheRootPath" /> 为空白字符串时抛出。
    /// </exception>
    /// <remarks>
    ///     该重载用于把与 Godot 引擎强耦合的环境行为收敛到可替换委托中。
    ///     编辑器态下，<c>res://</c> 可以被全局化后直接交给底层 <see cref="YamlConfigLoader" />；
    ///     导出态下，则需要先同步到 <c>user://</c> 缓存再切换到普通文件系统路径。
    /// </remarks>
    internal GodotYamlConfigLoader(
        GodotYamlConfigLoaderOptions options,
        GodotYamlConfigEnvironment environment)
    {
        ArgumentNullException.ThrowIfNull(options);
        ArgumentNullException.ThrowIfNull(environment);

        if (string.IsNullOrWhiteSpace(options.SourceRootPath))
        {
            throw new ArgumentException("SourceRootPath cannot be null or whitespace.", nameof(options));
        }

        if (string.IsNullOrWhiteSpace(options.RuntimeCacheRootPath))
        {
            throw new ArgumentException("RuntimeCacheRootPath cannot be null or whitespace.", nameof(options));
        }

        _options = options;
        _environment = environment;
        LoaderRootPath = ResolveLoaderRootPath();
        _loader = new YamlConfigLoader(
            LoaderRootPath,
            () => CanEnableHotReload,
            HotReloadUnavailableMessage);
        options.ConfigureLoader?.Invoke(_loader);
    }

    /// <summary>
    ///     获取配置源根目录。
    /// </summary>
    public string SourceRootPath => _options.SourceRootPath;

    /// <summary>
    ///     获取运行时缓存根目录。
    /// </summary>
    public string RuntimeCacheRootPath => _options.RuntimeCacheRootPath;

    /// <summary>
    ///     获取底层 <see cref="YamlConfigLoader" /> 实际使用的普通文件系统根目录。
    /// </summary>
    public string LoaderRootPath { get; }

    /// <summary>
    ///     获取底层 <see cref="YamlConfigLoader" /> 实例。
    ///     调用方可继续在该实例上追加注册表定义或读取注册数量。
    /// </summary>
    /// <remarks>
    ///     该实例仅应用于补充注册表定义或检查注册状态。
    ///     不要直接调用 <see cref="YamlConfigLoader.LoadAsync(GFramework.Game.Abstractions.Config.IConfigRegistry,System.Threading.CancellationToken)" />
    ///     或 <see cref="YamlConfigLoader.EnableHotReload(GFramework.Game.Abstractions.Config.IConfigRegistry,YamlConfigHotReloadOptions?)" />；
    ///     应分别改为调用 <see cref="LoadAsync" /> 与 <see cref="EnableHotReload" />，以确保 Godot 适配层先执行缓存同步并维持
    ///     <see cref="CanEnableHotReload" /> 守卫。
    /// </remarks>
    public YamlConfigLoader Loader => _loader;

    /// <summary>
    ///     获取一个值，指示当前实例是否可直接针对源目录启用热重载。
    /// </summary>
    public bool CanEnableHotReload => UsesSourceDirectoryDirectly(SourceRootPath);

    /// <summary>
    ///     执行 Godot 场景下的配置加载。
    ///     当源目录无法直接作为普通文件系统目录访问时，加载器会先把显式声明的 YAML 与 schema 文本同步到运行时缓存，
    ///     再委托底层 <see cref="YamlConfigLoader" /> 完成解析与注册。
    /// </summary>
    /// <param name="registry">用于接收配置表的注册表。</param>
    /// <param name="cancellationToken">取消令牌。</param>
    /// <returns>表示加载流程的异步任务。</returns>
    /// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为 <see langword="null" /> 时抛出。</exception>
    /// <exception cref="ConfigLoadException">
    ///     当缓存同步、配置文件读取、schema 读取或底层 YAML 加载失败时抛出。
    /// </exception>
    /// <remarks>
    ///     运行时缓存同步阶段刻意保持同步执行。
    ///     原因在于默认宿主环境可能需要通过 Godot 的目录和文件访问 API 读取 <c>res://</c> 资源，
    ///     而这些访问边界目前仅以同步委托形式暴露；同时底层 <see cref="YamlConfigLoader" /> 也要求缓存文件在开始读取前已经完整落盘。
    ///     这意味着当实例无法直接访问源目录时，调用线程会在进入真正的异步 YAML 解析前承担一次文件系统同步成本。
    /// </remarks>
    public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(registry);

        if (!CanEnableHotReload)
        {
            // Runtime cache preparation must finish before the underlying loader starts enumerating files.
            // This step intentionally stays synchronous because the default Godot environment exposes
            // directory enumeration and file reads through synchronous engine/file-system APIs only.
            SynchronizeRuntimeCache(cancellationToken);
        }

        await _loader.LoadAsync(registry, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    ///     在当前环境允许的情况下启用底层 YAML 热重载。
    /// </summary>
    /// <param name="registry">要被热重载更新的配置注册表。</param>
    /// <param name="options">热重载选项；为空时使用默认值。</param>
    /// <returns>用于停止监听的注销句柄。</returns>
    /// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为 <see langword="null" /> 时抛出。</exception>
    /// <exception cref="InvalidOperationException">
    ///     当当前实例必须通过运行时缓存访问配置源，无法直接监听真实源目录时抛出。
    /// </exception>
    /// <exception cref="ArgumentOutOfRangeException">
    ///     当 <paramref name="options" /> 的防抖延迟小于 <see cref="TimeSpan.Zero" /> 时，
    ///     底层 <see cref="YamlConfigLoader" /> 会拒绝启用热重载。
    /// </exception>
    /// <remarks>
    ///     调用前应先检查 <see cref="CanEnableHotReload" />。
    ///     当 <see cref="SourceRootPath" /> 只能通过缓存同步访问时，拒绝启用热重载是为了避免监听缓存副本后误导调用方，
    ///     让其误以为源目录改动会被自动反映到运行时。
    /// </remarks>
    public IUnRegister EnableHotReload(
        IConfigRegistry registry,
        YamlConfigHotReloadOptions? options = null)
    {
        ArgumentNullException.ThrowIfNull(registry);

        if (!CanEnableHotReload)
        {
            throw new InvalidOperationException(HotReloadUnavailableMessage);
        }

        return _loader.EnableHotReload(registry, options);
    }

    private string ResolveLoaderRootPath()
    {
        if (UsesSourceDirectoryDirectly(SourceRootPath))
        {
            return EnsureAbsolutePath(SourceRootPath, nameof(GodotYamlConfigLoaderOptions.SourceRootPath));
        }

        return EnsureAbsolutePath(RuntimeCacheRootPath, nameof(GodotYamlConfigLoaderOptions.RuntimeCacheRootPath));
    }

    private bool UsesSourceDirectoryDirectly(string sourceRootPath)
    {
        if (!sourceRootPath.IsGodotPath())
        {
            return true;
        }

        if (sourceRootPath.IsUserPath())
        {
            return true;
        }

        return sourceRootPath.IsResPath() && _environment.IsEditor();
    }

    private void SynchronizeRuntimeCache(CancellationToken cancellationToken)
    {
        foreach (var group in _options.TableSources
                     .GroupBy(static source => NormalizeRelativePath(source.ConfigRelativePath),
                         StringComparer.Ordinal)
                     // Parent directories must be reset before children, otherwise resetting "a" later
                     // would erase files that were already synchronized into "a/b" during the same pass.
                     .OrderBy(static group => CountPathDepth(group.Key))
                     .ThenBy(static group => group.Key, StringComparer.Ordinal))
        {
            cancellationToken.ThrowIfCancellationRequested();

            var representative = group.First();
            var sourceDirectoryPath = CombinePath(SourceRootPath, representative.ConfigRelativePath);
            var targetDirectoryPath = CombineAbsolutePath(LoaderRootPath, representative.ConfigRelativePath);

            ResetDirectory(representative.TableName, sourceDirectoryPath, targetDirectoryPath);
            CopyYamlFilesInDirectory(
                representative.TableName,
                sourceDirectoryPath,
                targetDirectoryPath,
                cancellationToken);
        }

        foreach (var group in _options.TableSources
                     .Where(static source => !string.IsNullOrEmpty(source.SchemaRelativePath))
                     .GroupBy(static source => NormalizeRelativePath(source.SchemaRelativePath!),
                         StringComparer.Ordinal))
        {
            cancellationToken.ThrowIfCancellationRequested();

            var representative = group.First();
            var sourceSchemaPath = CombinePath(SourceRootPath, representative.SchemaRelativePath!);
            var targetSchemaPath = CombineAbsolutePath(LoaderRootPath, representative.SchemaRelativePath!);

            CopySingleFile(
                representative.TableName,
                sourceSchemaPath,
                targetSchemaPath,
                ConfigLoadFailureKind.SchemaFileNotFound,
                ConfigLoadFailureKind.SchemaReadFailed);
        }
    }

    private void CopyYamlFilesInDirectory(
        string tableName,
        string sourceDirectoryPath,
        string targetDirectoryPath,
        CancellationToken cancellationToken)
    {
        var entries = _environment.EnumerateDirectory(sourceDirectoryPath);
        if (entries == null)
        {
            throw CreateConfigLoadException(
                ConfigLoadFailureKind.ConfigDirectoryNotFound,
                tableName,
                $"Config directory '{DescribePath(sourceDirectoryPath)}' was not found while preparing the Godot runtime cache.",
                configDirectoryPath: DescribePath(sourceDirectoryPath));
        }

        foreach (var entry in entries)
        {
            cancellationToken.ThrowIfCancellationRequested();

            if (entry.IsDirectory || entry.Name is "." or ".." || entry.Name.StartsWith(".", StringComparison.Ordinal))
            {
                continue;
            }

            if (!IsYamlFile(entry.Name))
            {
                continue;
            }

            var sourceFilePath = CombinePath(sourceDirectoryPath, entry.Name);
            var targetFilePath = Path.Combine(targetDirectoryPath, entry.Name);
            CopySingleFile(
                tableName,
                sourceFilePath,
                targetFilePath,
                ConfigLoadFailureKind.ConfigFileReadFailed,
                ConfigLoadFailureKind.ConfigFileReadFailed,
                configDirectoryPath: DescribePath(sourceDirectoryPath),
                yamlPath: DescribePath(sourceFilePath));
        }
    }

    private void CopySingleFile(
        string tableName,
        string sourceFilePath,
        string targetAbsolutePath,
        ConfigLoadFailureKind missingFailureKind,
        ConfigLoadFailureKind readFailureKind,
        string? configDirectoryPath = null,
        string? yamlPath = null)
    {
        if (!_environment.FileExists(sourceFilePath))
        {
            var missingMessage = missingFailureKind == ConfigLoadFailureKind.SchemaFileNotFound
                ? $"Schema file '{DescribePath(sourceFilePath)}' was not found while preparing the Godot runtime cache."
                : $"Config file '{DescribePath(sourceFilePath)}' was not found while preparing the Godot runtime cache.";

            throw CreateConfigLoadException(
                missingFailureKind,
                tableName,
                missingMessage,
                configDirectoryPath: configDirectoryPath,
                yamlPath: missingFailureKind == ConfigLoadFailureKind.SchemaFileNotFound
                    ? null
                    : yamlPath ?? DescribePath(sourceFilePath),
                schemaPath: missingFailureKind == ConfigLoadFailureKind.SchemaFileNotFound
                    ? DescribePath(sourceFilePath)
                    : null);
        }

        try
        {
            var parentDirectory = Path.GetDirectoryName(targetAbsolutePath);
            if (!string.IsNullOrWhiteSpace(parentDirectory))
            {
                Directory.CreateDirectory(parentDirectory);
            }

            File.WriteAllBytes(targetAbsolutePath, _environment.ReadAllBytes(sourceFilePath));
        }
        catch (Exception exception)
        {
            var readMessage = readFailureKind == ConfigLoadFailureKind.SchemaReadFailed
                ? $"Failed to copy schema file '{DescribePath(sourceFilePath)}' into the Godot runtime cache."
                : $"Failed to copy config file '{DescribePath(sourceFilePath)}' into the Godot runtime cache.";

            throw CreateConfigLoadException(
                readFailureKind,
                tableName,
                readMessage,
                configDirectoryPath: configDirectoryPath,
                yamlPath: readFailureKind == ConfigLoadFailureKind.SchemaReadFailed
                    ? null
                    : yamlPath ?? DescribePath(sourceFilePath),
                schemaPath: readFailureKind == ConfigLoadFailureKind.SchemaReadFailed
                    ? DescribePath(sourceFilePath)
                    : null,
                innerException: exception);
        }
    }

    private void ResetDirectory(string tableName, string sourceDirectoryPath, string targetDirectoryPath)
    {
        try
        {
            if (Directory.Exists(targetDirectoryPath))
            {
                Directory.Delete(targetDirectoryPath, recursive: true);
            }

            Directory.CreateDirectory(targetDirectoryPath);
        }
        catch (Exception exception)
        {
            var describedSourceDirectoryPath = DescribePath(sourceDirectoryPath);
            throw CreateConfigLoadException(
                ConfigLoadFailureKind.ConfigFileReadFailed,
                tableName,
                $"Failed to reset runtime cache directory '{targetDirectoryPath}' while preparing config directory '{describedSourceDirectoryPath}'.",
                configDirectoryPath: describedSourceDirectoryPath,
                detail: $"Runtime cache directory: {targetDirectoryPath}.",
                innerException: exception);
        }
    }

    private string EnsureAbsolutePath(string path, string optionName)
    {
        if (string.IsNullOrWhiteSpace(path))
        {
            throw new ArgumentException("Path cannot be null or whitespace.", optionName);
        }

        if (path.IsGodotPath())
        {
            var absolutePath = _environment.GlobalizePath(path);
            if (string.IsNullOrWhiteSpace(absolutePath))
            {
                throw new InvalidOperationException(
                    $"Path option '{optionName}' resolved to an empty absolute path. Value='{path}'.");
            }

            return absolutePath;
        }

        return Path.GetFullPath(path);
    }

    private string DescribePath(string path)
    {
        if (path.IsGodotPath())
        {
            var absolutePath = _environment.GlobalizePath(path);
            return string.IsNullOrWhiteSpace(absolutePath) ? path : absolutePath;
        }

        return Path.GetFullPath(path);
    }

    private static string CombinePath(string rootPath, string relativePath)
    {
        var normalizedRelativePath = NormalizeRelativePath(relativePath);
        if (rootPath.IsGodotPath())
        {
            if (rootPath.EndsWith("://", StringComparison.Ordinal))
            {
                return $"{rootPath}{normalizedRelativePath}";
            }

            return $"{rootPath.TrimEnd('/')}/{normalizedRelativePath}";
        }

        return Path.Combine(rootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar));
    }

    private static string CombineAbsolutePath(string rootPath, string relativePath)
    {
        return Path.Combine(rootPath, NormalizeRelativePath(relativePath).Replace('/', Path.DirectorySeparatorChar));
    }

    private static string NormalizeRelativePath(string relativePath)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);

        var normalizedPath = relativePath.Replace('\\', '/').Trim();
        if (normalizedPath.StartsWith("/", StringComparison.Ordinal) ||
            normalizedPath.StartsWith("res://", StringComparison.Ordinal) ||
            normalizedPath.StartsWith("user://", StringComparison.Ordinal) ||
            Path.IsPathRooted(normalizedPath) ||
            HasWindowsDrivePrefix(normalizedPath))
        {
            throw new ArgumentException("Relative path must be an unrooted path.", nameof(relativePath));
        }

        // Reject ':' in later segments as well so Windows-invalid names and ADS-like syntax never reach file APIs.
        if (normalizedPath.Contains(':', StringComparison.Ordinal))
        {
            throw new ArgumentException(
                "Relative path must not contain ':' characters.",
                nameof(relativePath));
        }

        var segments = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
        if (segments.Any(static segment => segment is "." or ".."))
        {
            throw new ArgumentException(
                "Relative path must not contain '.' or '..' segments.",
                nameof(relativePath));
        }

        return string.Join('/', segments);
    }

    private static int CountPathDepth(string normalizedRelativePath)
    {
        return normalizedRelativePath.Count(static ch => ch == '/');
    }

    private static bool HasWindowsDrivePrefix(string path)
    {
        return path.Length >= 2 &&
               char.IsLetter(path[0]) &&
               path[1] == ':';
    }

    private static bool IsYamlFile(string fileName)
    {
        return fileName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
               fileName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase);
    }

    private static ConfigLoadException CreateConfigLoadException(
        ConfigLoadFailureKind failureKind,
        string tableName,
        string message,
        string? configDirectoryPath = null,
        string? yamlPath = null,
        string? schemaPath = null,
        string? detail = null,
        Exception? innerException = null)
    {
        return new ConfigLoadException(
            new ConfigLoadDiagnostic(
                failureKind,
                tableName,
                configDirectoryPath: configDirectoryPath,
                yamlPath: yamlPath,
                schemaPath: schemaPath,
                detail: detail),
            message,
            innerException);
    }
}
