using Json.Schema;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Zcbot.WindowsNode;

internal interface INodeAdapter
{
    string Capability { get; }
    string DisplayName { get; }
    string AdapterVersion { get; }
    IReadOnlyList<string> Features { get; }
    bool HasActiveJobs { get; }
    string RunningDetail { get; }
    string? RuntimeId { get; }
    bool SupportsLocalAcceptance { get; }
    string RuntimePath { get; }
    string ContractPath { get; }
    string ContractSha256 { get; }
    string? WorkspaceStateFilename { get; }
    IReadOnlyList<string> PreviewOutputIds { get; }
    AdapterRuntimeStatus DetectRuntime();
    void InvalidateRuntime();
    Task<AdapterAcceptanceResult> RunLocalAcceptanceAsync(
        string workRoot,
        IProgress<string> progress,
        CancellationToken cancellationToken);
    bool ValidateRequest(JsonElement request);
    Task RunAsync(RecoverableJob job);
    void Cancel(Guid jobId);
}

internal sealed record AdapterManifest(
    string Capability,
    string AdapterVersion,
    string Runtime,
    string? RuntimeId,
    string Entrypoint,
    string Contract,
    int WorkerTimeoutMinutes,
    string RunningDetail);

internal sealed record AdapterAcceptanceResult(
    bool Passed,
    string ReportPath,
    string Detail);

internal sealed record NodeAdapterContract(
    string Capability,
    string DisplayName,
    IReadOnlyList<string> Features,
    JsonSchema RequestSchema,
    string? WorkspaceStateFilename,
    IReadOnlyList<string> PreviewOutputIds);

internal sealed record AdapterDescriptor(
    string DirectoryPath,
    string EntrypointPath,
    string ContractPath,
    string ContractSha256,
    AdapterManifest Manifest,
    NodeAdapterContract Contract)
{
    internal static AdapterDescriptor Load(string directory)
    {
        var root = Path.GetFullPath(directory);
        var manifestPath = ResolveFile(root, "adapter.json");
        using var manifestDocument = JsonDocument.Parse(File.ReadAllBytes(manifestPath));
        var manifestRoot = manifestDocument.RootElement;
        RequireOnlyProperties(
            manifestRoot, "capability", "adapter_version", "runtime", "runtime_id",
            "entrypoint", "contract", "worker_timeout_minutes", "running_detail");
        var manifest = new AdapterManifest(
            RequiredString(manifestRoot, "capability"),
            RequiredVersion(manifestRoot, "adapter_version"),
            RequiredString(manifestRoot, "runtime"),
            OptionalString(manifestRoot, "runtime_id"),
            RequiredString(manifestRoot, "entrypoint"),
            RequiredString(manifestRoot, "contract"),
            OptionalInteger(manifestRoot, "worker_timeout_minutes", 30, 1, 1440),
            RequiredString(manifestRoot, "running_detail"));
        if (!Regex.IsMatch(manifest.Capability, "^[a-z][a-z0-9_.-]+@v[1-9][0-9]*$"))
        {
            throw new InvalidDataException("Adapter capability is invalid.");
        }
        if (manifest.Runtime is not ("python" or "executable"))
        {
            throw new InvalidDataException("Adapter runtime must be python or executable.");
        }
        if (manifest.Runtime == "python"
            && (manifest.RuntimeId is null
                || !Regex.IsMatch(manifest.RuntimeId, "^[a-z][a-z0-9_-]{0,31}$")))
        {
            throw new InvalidDataException("Python adapter runtime_id is invalid.");
        }
        if (manifest.Runtime == "executable" && manifest.RuntimeId is not null)
        {
            throw new InvalidDataException("Executable adapter must not declare runtime_id.");
        }

        var contractPath = ResolveFile(root, manifest.Contract);
        var contractBytes = File.ReadAllBytes(contractPath);
        var contractSha256 = Convert.ToHexString(SHA256.HashData(contractBytes)).ToLowerInvariant();
        using var contractDocument = JsonDocument.Parse(contractBytes);
        var contractRoot = contractDocument.RootElement;
        var capability = RequiredString(contractRoot, "capability");
        if (!capability.Equals(manifest.Capability, StringComparison.Ordinal))
        {
            throw new InvalidDataException("Adapter manifest capability does not match its contract.");
        }
        var displayName = RequiredString(contractRoot, "display_name");
        var currentVersion = ParseVersion(manifest.AdapterVersion);
        var features = contractRoot.GetProperty("features").EnumerateObject()
            .Where(item => ParseVersion(item.Value.GetString() ?? "0.0.0") <= currentVersion)
            .Select(item => item.Name)
            .ToArray();
        string? workspaceStateFilename = null;
        IReadOnlyList<string> previewOutputIds = [];
        if (contractRoot.TryGetProperty("workspace", out var workspace)
            && workspace.ValueKind == JsonValueKind.Object)
        {
            var stateOutput = RequiredString(workspace, "state_output");
            var outputs = contractRoot.GetProperty("outputs");
            if (!outputs.TryGetProperty(stateOutput, out var stateSpec))
            {
                throw new InvalidDataException("Workspace state output is missing from contract.");
            }
            workspaceStateFilename = RequiredString(stateSpec, "filename");
            previewOutputIds = workspace.GetProperty("preview_outputs")
                .EnumerateArray()
                .Select(item => item.GetString()
                    ?? throw new InvalidDataException("Workspace preview output is invalid."))
                .ToArray();
        }
        // JsonSchema may retain the source element for deferred evaluation.  Give it an
        // independent backing document before contractDocument is disposed.
        var schema = JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone());
        var entrypointPath = ResolveFile(root, manifest.Entrypoint);
        if ((manifest.Runtime == "python"
                && !Path.GetExtension(entrypointPath).Equals(".py", StringComparison.OrdinalIgnoreCase))
            || (manifest.Runtime == "executable"
                && !Path.GetExtension(entrypointPath).Equals(".exe", StringComparison.OrdinalIgnoreCase)))
        {
            throw new InvalidDataException("Adapter entrypoint extension does not match its runtime.");
        }
        return new AdapterDescriptor(
            root,
            entrypointPath,
            contractPath,
            contractSha256,
            manifest,
            new NodeAdapterContract(
                capability,
                displayName,
                features,
                schema,
                workspaceStateFilename,
                previewOutputIds));
    }

    private static string ResolveFile(string root, string relativePath)
    {
        if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathFullyQualified(relativePath))
        {
            throw new InvalidDataException("Adapter file path must be relative.");
        }
        var resolved = Path.GetFullPath(Path.Combine(root, relativePath));
        if (!resolved.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
            || !File.Exists(resolved))
        {
            throw new InvalidDataException("Adapter file is missing or outside its directory.");
        }
        return resolved;
    }

    private static void RequireOnlyProperties(JsonElement value, params string[] names)
    {
        if (value.ValueKind != JsonValueKind.Object)
        {
            throw new InvalidDataException("Adapter manifest must be an object.");
        }
        var allowed = names.ToHashSet(StringComparer.Ordinal);
        if (value.EnumerateObject().Any(item => !allowed.Contains(item.Name)))
        {
            throw new InvalidDataException("Adapter manifest contains unknown properties.");
        }
    }

    private static string RequiredString(JsonElement value, string name) =>
        value.TryGetProperty(name, out var property)
        && property.ValueKind == JsonValueKind.String
        && !string.IsNullOrWhiteSpace(property.GetString())
            ? property.GetString()!
            : throw new InvalidDataException($"Adapter property {name} is missing.");

    private static string RequiredVersion(JsonElement value, string name)
    {
        var version = RequiredString(value, name);
        if (!Regex.IsMatch(version, "^[0-9]+\\.[0-9]+\\.[0-9]+$")
            || !Version.TryParse(version, out _))
        {
            throw new InvalidDataException($"Adapter property {name} is not a semantic version.");
        }
        return version;
    }

    private static int OptionalInteger(
        JsonElement value, string name, int defaultValue, int minimum, int maximum)
    {
        if (!value.TryGetProperty(name, out var property)) return defaultValue;
        return property.TryGetInt32(out var result)
            && result >= minimum
            && result <= maximum
                ? result
                : throw new InvalidDataException($"Adapter property {name} is invalid.");
    }

    private static string? OptionalString(JsonElement value, string name) =>
        !value.TryGetProperty(name, out var property) || property.ValueKind == JsonValueKind.Null
            ? null
            : property.ValueKind == JsonValueKind.String
                ? property.GetString()
                : throw new InvalidDataException($"Adapter property {name} must be a string.");

    private static Version ParseVersion(string value) =>
        Version.TryParse(value, out var version) ? version : new Version(0, 0, 0);
}

internal sealed class ProcessNodeAdapter : INodeAdapter
{
    private static readonly EvaluationOptions SchemaOptions = new()
    {
        OutputFormat = OutputFormat.Flag,
        RequireFormatValidation = true,
    };
    private readonly AdapterProcessRunner runner;
    private AdapterRuntimeStatus? cachedRuntime;
    private DateTimeOffset runtimeCheckedAt;

    internal ProcessNodeAdapter(AdapterDescriptor descriptor, JobInboxStore inbox)
    {
        Descriptor = descriptor;
        runner = new AdapterProcessRunner(descriptor, inbox);
    }

    internal AdapterDescriptor Descriptor { get; }
    public string Capability => Descriptor.Contract.Capability;
    public string DisplayName => Descriptor.Contract.DisplayName;
    public string AdapterVersion => Descriptor.Manifest.AdapterVersion;
    public IReadOnlyList<string> Features => Descriptor.Contract.Features;
    public bool HasActiveJobs => runner.HasActiveJobs;
    public string RunningDetail => Descriptor.Manifest.RunningDetail;
    public string? RuntimeId => Descriptor.Manifest.RuntimeId;
    public bool SupportsLocalAcceptance =>
        Capability.Equals(
            "ansys.mechanical.static_structural@v2", StringComparison.Ordinal)
        && File.Exists(Path.Combine(Descriptor.DirectoryPath, "acceptance.py"));
    public string RuntimePath => runner.RuntimePath;
    public string ContractPath => Descriptor.ContractPath;
    public string ContractSha256 => Descriptor.ContractSha256;
    public string? WorkspaceStateFilename => Descriptor.Contract.WorkspaceStateFilename;
    public IReadOnlyList<string> PreviewOutputIds => Descriptor.Contract.PreviewOutputIds;

    public AdapterRuntimeStatus DetectRuntime()
    {
        if (cachedRuntime is null
            || DateTimeOffset.UtcNow - runtimeCheckedAt > TimeSpan.FromSeconds(30))
        {
            cachedRuntime = runner.Probe();
            runtimeCheckedAt = DateTimeOffset.UtcNow;
        }
        return cachedRuntime;
    }

    public void InvalidateRuntime()
    {
        cachedRuntime = null;
        runtimeCheckedAt = default;
    }

    public bool ValidateRequest(JsonElement request) =>
        Descriptor.Contract.RequestSchema.Evaluate(request, SchemaOptions).IsValid;

    public Task<AdapterAcceptanceResult> RunLocalAcceptanceAsync(
        string workRoot,
        IProgress<string> progress,
        CancellationToken cancellationToken) => SupportsLocalAcceptance
            ? runner.RunAcceptanceAsync(workRoot, progress, cancellationToken)
            : throw new InvalidOperationException(
                "This adapter does not provide a local acceptance suite.");

    public Task RunAsync(RecoverableJob job) => runner.RunAsync(job);
    public void Cancel(Guid jobId) => runner.Cancel(jobId);
}

internal sealed class NodeAdapterRegistry
{
    private readonly IReadOnlyDictionary<string, INodeAdapter> adapters;

    internal NodeAdapterRegistry(IEnumerable<INodeAdapter> values)
    {
        adapters = values.ToDictionary(item => item.Capability, StringComparer.Ordinal);
    }

    internal static string AdapterRoot =>
        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "adapters"));

    internal static NodeAdapterRegistry CreateDefault(JobInboxStore inbox) =>
        new(DiscoverDescriptors().Select(item => new ProcessNodeAdapter(item, inbox)));

    internal static IReadOnlyList<NodeAdapterContract> InstalledContracts =>
        DiscoverDescriptors().Select(item => item.Contract).ToArray();

    internal static IReadOnlyList<string> InstalledCapabilities =>
        InstalledContracts.Select(item => item.Capability).ToArray();

    internal IReadOnlyCollection<INodeAdapter> All => adapters.Values.ToArray();

    internal INodeAdapter? Find(string capability) =>
        adapters.TryGetValue(capability, out var adapter) ? adapter : null;

    internal void InvalidateRuntime(string runtimeId)
    {
        foreach (var adapter in adapters.Values.Where(
            item => runtimeId.Equals(item.RuntimeId, StringComparison.Ordinal)))
        {
            adapter.InvalidateRuntime();
        }
    }

    private static IReadOnlyList<AdapterDescriptor> DiscoverDescriptors()
    {
        if (!Directory.Exists(AdapterRoot)) return [];
        return Directory.EnumerateDirectories(AdapterRoot)
            .Where(path => File.Exists(Path.Combine(path, "adapter.json")))
            .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
            .Select(AdapterDescriptor.Load)
            .ToArray();
    }
}
