using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Nexus.Api.Controllers;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;

namespace Nexus.Api.Tests;

public sealed class OpenClawAgentConfigurationServiceTests
{
    [Fact]
    public async Task AgentFileRead_UsesGatewayContentAndDoesNotExposeHostPaths()
    {
        var gateway = Connected(
            ["agents.files.get"],
            ["operator.read"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """
            {
              "agentId": "product-owner",
              "workspace": "/home/node/.openclaw/workspace-po",
              "file": {
                "name": "AGENTS.md",
                "path": "/home/node/.openclaw/workspace-po/AGENTS.md",
                "missing": false,
                "size": 18,
                "updatedAtMs": 1785000000000,
                "content": "# Standing orders"
              }
            }
            """);
        var service = CreateService(gateway);

        var result = await service.GetAgentFileAsync("product-owner", "agents.md");

        Assert.Equal("product-owner", result.AgentId);
        Assert.Equal("AGENTS.md", result.Name);
        Assert.Equal("# Standing orders", result.Content);
        Assert.Equal(Hash("# Standing orders"), result.ContentHash);
        Assert.DoesNotContain(
            result.GetType().GetProperties(),
            property => property.Name is "Path" or "Workspace");
        var invocation = Assert.Single(gateway.Invocations);
        Assert.Equal("agents.files.get", invocation.Method);
        Assert.Equal("AGENTS.md", invocation.Parameters?["name"]?.GetValue<string>());
    }

    [Fact]
    public async Task AgentFileList_OnlyReturnsSupportedFiles()
    {
        var gateway = Connected(
            ["agents.files.list"],
            ["operator.read"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """
            {
              "workspace": "/home/node/.openclaw/workspace",
              "files": [
                { "name": "SOUL.md", "path": "/home/node/.openclaw/workspace/SOUL.md", "missing": false },
                { "name": "MEMORY.md", "path": "/home/node/.openclaw/workspace/MEMORY.md", "missing": true },
                { "name": "DREAMS.md", "path": "/home/node/.openclaw/workspace/DREAMS.md", "missing": false }
              ]
            }
            """);
        var service = CreateService(gateway);

        var result = await service.GetAgentFilesAsync("main");

        Assert.Equal(["SOUL.md", "MEMORY.md"], result.Files.Select(file => file.Name));
        Assert.Equal(
            OpenClawAgentConfigurationService.MissingContentHash,
            result.Files.Single(file => file.Name == "MEMORY.md").ContentHash);
    }

    [Fact]
    public async Task AgentFileWrite_RejectsStaleExpectedHashBeforeMutation()
    {
        var gateway = Connected(
            ["agents.files.get", "agents.files.set"],
            ["operator.admin"]);
        gateway.Handler = (method, _) => method == "agents.files.get"
            ? AgentFile("SOUL.md", "current")
            : JsonNode.Parse("""{ "ok": true }""");
        var audit = new FakeOperationAuditStore();
        var service = CreateService(gateway, audit);

        var exception = await Assert.ThrowsAsync<OpenClawAgentConfigurationConflictException>(
            () => service.SetAgentFileAsync(
                "iris",
                "SOUL.md",
                new UpdateOpenClawAgentFileRequest("next", new string('0', 64)),
                Invocation()));

        Assert.Equal("content_hash_mismatch", exception.Code);
        Assert.Equal(Hash("current"), exception.CurrentHash);
        Assert.DoesNotContain(gateway.Invocations, item => item.Method == "agents.files.set");
        var completion = Assert.Single(audit.Completions);
        Assert.False(completion.Ok);
        Assert.Equal("conflict", completion.State);
    }

    [Fact]
    public async Task AgentFileWrite_IsReadBackVerifiedAndCarriesInvocationMetadata()
    {
        var gateway = Connected(
            ["agents.files.get", "agents.files.set"],
            ["operator.admin"]);
        var reads = 0;
        gateway.Handler = (method, parameters) =>
        {
            if (method == "agents.files.set")
            {
                Assert.Null(parameters?["idempotencyKey"]);
                Assert.Equal("next", parameters?["content"]?.GetValue<string>());
                return JsonNode.Parse("""{ "ok": true }""");
            }

            reads++;
            return AgentFile("SOUL.md", reads == 1 ? "current" : "next");
        };
        var audit = new FakeOperationAuditStore();
        var service = CreateService(gateway, audit);
        var invocation = Invocation();

        var result = await service.SetAgentFileAsync(
            "iris",
            "SOUL.md",
            new UpdateOpenClawAgentFileRequest("next", Hash("current")),
            invocation);

        Assert.True(result.Ok);
        Assert.True(result.Verified);
        Assert.Equal("completed", result.State);
        Assert.Equal(invocation.IdempotencyKey, result.IdempotencyKey);
        Assert.Equal(Hash("next"), result.File.ContentHash);
        Assert.Equal(
            ["agents.files.get", "agents.files.set", "agents.files.get"],
            gateway.Invocations.Select(item => item.Method));
        Assert.Equal(invocation, gateway.Invocations[1].Context);
        Assert.True(Assert.Single(audit.Completions).Ok);
    }

    [Theory]
    [InlineData("../secrets/.env")]
    [InlineData("/home/node/.openclaw/openclaw.json")]
    [InlineData("memory/../../openclaw.json")]
    [InlineData("credentials.json")]
    public async Task WorkspaceRead_RejectsUnsafePathsBeforeGateway(string path)
    {
        var gateway = Connected(
            ["agents.workspace.get"],
            ["operator.read"]);
        var service = CreateService(gateway);

        await Assert.ThrowsAsync<OpenClawAgentConfigurationValidationException>(
            () => service.GetWorkspaceFileAsync("main", path));

        Assert.Empty(gateway.Invocations);
    }

    [Fact]
    public async Task WorkspaceList_FiltersSensitiveAndAbsoluteEntries()
    {
        var gateway = Connected(
            ["agents.workspace.list"],
            ["operator.read"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """
            {
              "agentId": "main",
              "path": "",
              "entries": [
                { "path": "DREAMS.md", "name": "DREAMS.md", "kind": "file", "size": 12 },
                { "path": ".env", "name": ".env", "kind": "file", "size": 20 },
                { "path": "/home/node/.openclaw/openclaw.json", "name": "openclaw.json", "kind": "file" }
              ],
              "totalEntries": 3,
              "offset": 0
            }
            """);
        var service = CreateService(gateway);

        var result = await service.GetWorkspaceAsync("main", null, 0, 250);

        var entry = Assert.Single(result.Entries);
        Assert.Equal("DREAMS.md", entry.Path);
    }

    [Fact]
    public async Task ConfigRead_RedactsSecretsAndAbsoluteHostPaths()
    {
        var gateway = Connected(
            ["config.get"],
            ["operator.read"]);
        gateway.Handler = (_, _) => ConfigSnapshot(
            new string('a', 64),
            new JsonObject
            {
                ["gateway"] = new JsonObject
                {
                    ["auth"] = new JsonObject { ["token"] = "super-secret-token" }
                },
                ["agents"] = new JsonObject
                {
                    ["defaults"] = new JsonObject
                    {
                        ["workspace"] = "/home/node/.openclaw/workspace"
                    }
                },
                ["safeValue"] = "visible"
            });
        var service = CreateService(gateway);

        var result = await service.GetConfigAsync();

        Assert.Equal(
            "[redacted]",
            result.Config?["gateway"]?["auth"]?["token"]?.GetValue<string>());
        Assert.Equal(
            "[host-path-redacted]",
            result.Config?["agents"]?["defaults"]?["workspace"]?.GetValue<string>());
        Assert.Equal("visible", result.Config?["safeValue"]?.GetValue<string>());
    }

    [Fact]
    public async Task ConfigPatch_UsesBaseHashReplacePathsAndValidReadBack()
    {
        var beforeHash = new string('a', 64);
        var afterHash = new string('b', 64);
        var gateway = Connected(
            ["config.get", "config.patch"],
            ["operator.admin"]);
        var reads = 0;
        gateway.Handler = (method, parameters) =>
        {
            if (method == "config.get")
            {
                reads++;
                return ConfigSnapshot(
                    reads == 1 ? beforeHash : afterHash,
                    new JsonObject
                    {
                        ["channels"] = new JsonObject
                        {
                            ["telegram"] = new JsonObject
                            {
                                ["enabled"] = reads > 1
                            }
                        }
                    });
            }

            Assert.Equal(beforeHash, parameters?["baseHash"]?.GetValue<string>());
            Assert.Contains(
                "\"enabled\":true",
                parameters?["raw"]?.GetValue<string>());
            Assert.Equal(
                "channels.telegram",
                parameters?["replacePaths"]?[0]?.GetValue<string>());
            Assert.Null(parameters?["idempotencyKey"]);
            return JsonNode.Parse(
                """{ "ok": true, "restart": { "required": false } }""");
        };
        var audit = new FakeOperationAuditStore();
        var service = CreateService(gateway, audit);
        var invocation = Invocation();

        var result = await service.PatchConfigAsync(
            new PatchOpenClawConfigRequest(
                new JsonObject
                {
                    ["channels"] = new JsonObject
                    {
                        ["telegram"] = new JsonObject { ["enabled"] = true }
                    }
                },
                beforeHash,
                ["channels.telegram"]),
            invocation);

        Assert.True(result.Ok);
        Assert.True(result.Verified);
        Assert.Equal(afterHash, result.Snapshot.Hash);
        Assert.Equal(
            ["config.get", "config.patch", "config.get"],
            gateway.Invocations.Select(item => item.Method));
        Assert.True(Assert.Single(audit.Completions).Ok);
    }

    [Fact]
    public async Task ConfigPatch_RejectsLiteralSecretsBeforeGatewayOrAudit()
    {
        var gateway = Connected(
            ["config.patch"],
            ["operator.admin"]);
        var audit = new FakeOperationAuditStore();
        var service = CreateService(gateway, audit);

        var exception = await Assert.ThrowsAsync<OpenClawAgentConfigurationValidationException>(
            () => service.PatchConfigAsync(
                new PatchOpenClawConfigRequest(
                    new JsonObject
                    {
                        ["models"] = new JsonObject
                        {
                            ["providers"] = new JsonObject
                            {
                                ["openai"] = new JsonObject { ["apiKey"] = "sk-test-secret" }
                            }
                        }
                    },
                    new string('a', 64)),
                Invocation()));

        Assert.Equal("patch", exception.Field);
        Assert.Empty(gateway.Invocations);
        Assert.Empty(audit.Claims);
    }

    [Fact]
    public async Task Controller_MapsContentConflictTo409AndEchoesIdempotencyKey()
    {
        var authorize = Assert.Single(
            typeof(OpenClawAgentConfigurationController)
                .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
                .Cast<AuthorizeAttribute>());
        Assert.Equal("owner", authorize.Roles);

        var gateway = Connected(
            ["agents.files.get", "agents.files.set"],
            ["operator.admin"]);
        gateway.Handler = (_, _) => AgentFile("AGENTS.md", "current");
        var service = CreateService(gateway);
        var controller = new OpenClawAgentConfigurationController(service);
        var httpContext = new DefaultHttpContext();
        httpContext.Request.Headers["Idempotency-Key"] = "agent-file-test-1";
        httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
            [
                new Claim("sub", "bao"),
                new Claim(ClaimTypes.Role, "owner")
            ],
            authenticationType: "test"));
        controller.ControllerContext = new ControllerContext { HttpContext = httpContext };

        var response = await controller.SetAgentFile(
            "main",
            "AGENTS.md",
            new UpdateOpenClawAgentFileRequest("next", new string('0', 64)),
            CancellationToken.None);

        var result = Assert.IsType<ObjectResult>(response.Result);
        Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode);
        Assert.Equal(
            "agent-file-test-1",
            httpContext.Response.Headers["Idempotency-Key"].ToString());
    }

    private static OpenClawAgentConfigurationService CreateService(
        AgentConfigurationStubConnector gateway,
        FakeOperationAuditStore? audit = null)
        => new(
            gateway,
            audit ?? new FakeOperationAuditStore(),
            new StubOpenClawWriteGate(),
            NullLogger<OpenClawAgentConfigurationService>.Instance);

    private static AgentConfigurationStubConnector Connected(
        IEnumerable<string> methods,
        IEnumerable<string> scopes)
        => new()
        {
            ConnectionState = GatewayConnectionState.Connected,
            AdvertisedMethods = methods.ToHashSet(StringComparer.Ordinal),
            GrantedScopes = scopes.ToHashSet(StringComparer.Ordinal)
        };

    private static OpenClawInvocationContext Invocation()
        => OpenClawInvocationContext.Create(
            actor: "bao",
            idempotencyKey: $"test-{Guid.NewGuid():N}",
            correlationId: $"corr-{Guid.NewGuid():N}");

    private static JsonNode? AgentFile(string name, string content)
        => JsonNode.Parse(
            $$"""
            {
              "file": {
                "name": "{{name}}",
                "path": "/home/node/.openclaw/workspace/{{name}}",
                "missing": false,
                "size": {{Encoding.UTF8.GetByteCount(content)}},
                "updatedAtMs": 1785000000000,
                "content": {{System.Text.Json.JsonSerializer.Serialize(content)}}
              }
            }
            """);

    private static JsonNode ConfigSnapshot(string hash, JsonNode config)
        => new JsonObject
        {
            ["exists"] = true,
            ["valid"] = true,
            ["hash"] = hash,
            ["config"] = config,
            ["issues"] = new JsonArray(),
            ["warnings"] = new JsonArray()
        };

    private static string Hash(string content)
        => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
}

internal sealed record AgentConfigurationInvocation(
    string Method,
    JsonNode? Parameters,
    OpenClawInvocationContext? Context);

internal sealed class AgentConfigurationStubConnector : IGatewayConnector
{
    public GatewayConnectionState ConnectionState { get; set; } = GatewayConnectionState.Initializing;
    public string? GatewayVersion { get; set; } = "2026.7.1";
    public string? RequiredVersion { get; set; }
    public DateTimeOffset? LastConnectedAt { get; set; }
    public int ReconnectAttempts { get; set; }
    public string? StatusMessage { get; set; }
    public string? DeviceId { get; set; }
    public bool DeviceTokenConfigured { get; set; }
    public bool PairingRequired { get; set; }
    public string? PairingRequestId { get; set; }
    public int? ProtocolVersion { get; set; } = 4;
    public IReadOnlySet<string> AdvertisedMethods { get; set; } =
        new HashSet<string>(StringComparer.Ordinal);
    public IReadOnlySet<string> AdvertisedEvents { get; set; } =
        new HashSet<string>(StringComparer.Ordinal);
    public IReadOnlySet<string> GrantedScopes { get; set; } =
        new HashSet<string>(StringComparer.Ordinal);
    public DateTimeOffset? LastEventAt { get; set; }
    public Func<string, JsonNode?, JsonNode?>? Handler { get; set; }
    public List<AgentConfigurationInvocation> Invocations { get; } = [];

    public bool Supports(string method) => AdvertisedMethods.Contains(method);

    public Task<JsonNode?> InvokeAsync(
        string method,
        object? parameters = null,
        TimeSpan? timeout = null,
        CancellationToken cancellationToken = default,
        OpenClawInvocationContext? invocationContext = null)
    {
        var node = parameters switch
        {
            null => null,
            JsonNode jsonNode => jsonNode.DeepClone(),
            _ => System.Text.Json.JsonSerializer.SerializeToNode(parameters)
        };
        Invocations.Add(new AgentConfigurationInvocation(method, node, invocationContext));
        return Task.FromResult(Handler?.Invoke(method, node));
    }

    public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
}

internal sealed class FakeOperationAuditStore : IOpenClawOperationAuditStore
{
    public string AuditPath => "test-only";
    public OpenClawOperationClaim NextClaim { get; set; } =
        new(OpenClawOperationClaimDisposition.Started);
    public List<(OpenClawInvocationContext Context, OpenClawOperationDescriptor Descriptor)> Claims
    {
        get;
    } = [];
    public List<(bool Ok, string State, string Message, string? ErrorCode)> Completions { get; } = [];

    public Task<OpenClawOperationClaim> ClaimAsync(
        OpenClawInvocationContext context,
        OpenClawOperationDescriptor operation,
        CancellationToken cancellationToken = default)
    {
        Claims.Add((context, operation));
        return Task.FromResult(NextClaim);
    }

    public Task CompleteAsync(
        OpenClawInvocationContext context,
        OpenClawOperationDescriptor operation,
        bool ok,
        string state,
        string message,
        string? errorCode = null,
        CancellationToken cancellationToken = default)
    {
        Completions.Add((ok, state, message, errorCode));
        return Task.CompletedTask;
    }
}
