using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Nexus.Api.Services;
using Xunit;

namespace Nexus.Api.Tests;

public sealed class OpenClawControlServiceTests
{
    [Fact]
    public async Task DisconnectedTaskList_ReportsRecoveryWithoutInvokingGateway()
    {
        var gateway = new StubOpenClawConnector
        {
            ConnectionState = GatewayConnectionState.Disconnected
        };
        var service = CreateService(gateway);

        var result = await service.GetTasksAsync();

        Assert.Equal("disconnected", result.State);
        Assert.Empty(result.Items);
        Assert.NotNull(result.Recovery);
        Assert.Empty(gateway.Invocations);
    }

    [Fact]
    public async Task DisconnectedMutation_WithInvalidMetadata_ReturnsControlledInvalidResult()
    {
        var gateway = new StubOpenClawConnector
        {
            ConnectionState = GatewayConnectionState.Disconnected
        };
        var service = CreateService(gateway);
        var invalidContext = new OpenClawInvocationContext(
            "idem-task-1",
            "corr-task-1",
            "owner-1",
            "not-a-traceparent");

        var result = await service.CancelTaskAsync(
            "task-1",
            null,
            invocationContext: invalidContext);

        Assert.False(result.Ok);
        Assert.Equal("invalid", result.State);
        Assert.Empty(gateway.Invocations);
    }

    [Fact]
    public void PairingRecovery_OnlyEmbedsShellSafeRequestId()
    {
        var gateway = new StubOpenClawConnector
        {
            ConnectionState = GatewayConnectionState.Disconnected,
            PairingRequired = true,
            PairingRequestId = "request'; remove-item secret"
        };
        var service = CreateService(gateway);

        var connection = service.GetConnection();

        Assert.True(connection.PairingRequired);
        Assert.Equal("request'; remove-item secret", connection.PairingRequestId);
        Assert.NotNull(connection.Recovery);
        Assert.DoesNotContain("request'; remove-item secret", connection.Recovery);
        Assert.DoesNotContain("openclaw devices approve", connection.Recovery);
    }

    [Fact]
    public async Task TaskList_NormalizesGatewayLedgerShape()
    {
        var gateway = Connected(
            methods: ["tasks.list", "tasks.cancel"],
            scopes: ["operator.read", "operator.write"]);
        gateway.Handler = (method, _) => method == "tasks.list"
            ? JsonNode.Parse(
                """
                {
                  "tasks": [
                    {
                      "id": "task-1",
                      "title": "Index workspace",
                      "status": "running",
                      "agentId": "researcher",
                      "sessionKey": "agent:researcher:main",
                      "runId": "run-1",
                      "startedAtMs": 1785000000000,
                      "progress": 42
                    },
                    {
                      "id": "task-2",
                      "title": "Review output",
                      "status": "completed"
                    }
                  ],
                  "nextCursor": "next-1"
                }
                """)
            : null;
        var service = CreateService(gateway);

        var result = await service.GetTasksAsync();

        Assert.Equal("ready", result.State);
        Assert.Equal("next-1", result.NextCursor);
        Assert.Equal(2, result.Items.Count);
        Assert.Equal("running", result.Items[0].Status);
        Assert.True(result.Items[0].CanCancel);
        Assert.Equal("succeeded", result.Items[1].Status);
        Assert.False(result.Items[1].CanCancel);
    }

    [Fact]
    public async Task CronList_MapsScheduleAndRequiresAdminForRun()
    {
        var gateway = Connected(
            methods: ["cron.list", "cron.run"],
            scopes: ["operator.read"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """
            {
              "jobs": [{
                "id": "job-1",
                "name": "Morning brief",
                "enabled": true,
                "schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "Europe/Berlin" },
                "state": { "nextRunAtMs": 1785000000000, "lastRunStatus": "ok" }
              }]
            }
            """);
        var service = CreateService(gateway);

        var result = await service.GetCronJobsAsync();

        var job = Assert.Single(result.Items);
        Assert.Equal("0 7 * * *", job.Schedule);
        Assert.Equal("Europe/Berlin", job.TimeZone);
        Assert.Equal("ok", job.Status);
        Assert.False(job.CanRun);
    }

    [Fact]
    public async Task CronList_ForwardsFiltersAndTranslatesOffsetCursor()
    {
        var gateway = Connected(
            methods: ["cron.list"],
            scopes: ["operator.read"]);
        gateway.Handler = (_, parameters) =>
        {
            var offset = parameters?["offset"]?.GetValue<int>() ?? 0;
            return offset == 0
                ? JsonNode.Parse(
                    """
                    {
                      "jobs": [{ "id": "job-1", "name": "First", "enabled": true,
                        "schedule": { "kind": "every", "everyMs": 60000 }, "state": {} }],
                      "nextOffset": 1
                    }
                    """)
                : JsonNode.Parse(
                    """
                    {
                      "jobs": [{ "id": "job-2", "name": "Second", "enabled": true,
                        "schedule": { "kind": "every", "everyMs": 120000 }, "state": {} }],
                      "nextOffset": null
                    }
                    """);
        };
        var service = CreateService(gateway);

        var first = await service.GetCronJobsAsync(
            includeDisabled: false,
            limit: 1);
        var second = await service.GetCronJobsAsync(
            includeDisabled: false,
            limit: 1,
            cursor: first.NextCursor);

        Assert.NotNull(first.NextCursor);
        Assert.Equal("job-2", Assert.Single(second.Items).Id);
        Assert.Collection(
            gateway.Invocations,
            invocation =>
            {
                Assert.Equal(false, invocation.Parameters?["includeDisabled"]?.GetValue<bool>());
                Assert.Equal(0, invocation.Parameters?["offset"]?.GetValue<int>());
            },
            invocation => Assert.Equal(1, invocation.Parameters?["offset"]?.GetValue<int>()));
    }

    [Fact]
    public async Task CronDetail_MapsEditableDefinitionAndStableResourceHash()
    {
        var gateway = Connected(
            methods: ["cron.get", "cron.update", "cron.remove", "cron.run"],
            scopes: ["operator.read", "operator.admin"]);
        gateway.Handler = (_, _) => CronJob(
            name: "Morning brief",
            enabled: true,
            deliveryTarget: "+49 151 12345678");
        var service = CreateService(gateway);

        var first = await service.GetCronJobAsync("job-1");
        var second = await service.GetCronJobAsync("job-1");

        Assert.True(first.Ok);
        Assert.NotNull(first.Data);
        Assert.Equal("cron", first.Data.Schedule.Kind);
        Assert.Equal("agentTurn", first.Data.Payload.Kind);
        Assert.Equal("+49 151 12345678", first.Data.Delivery?.Target);
        Assert.Equal(first.Data.ResourceHash, second.Data?.ResourceHash);
        Assert.True(first.Data.CanUpdate);
        Assert.True(first.Data.CanDelete);
    }

    [Fact]
    public async Task CronRuns_UsesRunFilterAndRedactsDeliveryTargets()
    {
        var gateway = Connected(
            methods: ["cron.runs"],
            scopes: ["operator.read"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """
            {
              "entries": [{
                "ts": 1785000000000,
                "jobId": "job-1",
                "runId": "run-1",
                "action": "finished",
                "status": "error",
                "summary": "Delivery to +49 151 12345678 failed",
                "deliveryError": "Webhook https://example.invalid/hooks/secret failed"
              }],
              "nextOffset": 1
            }
            """);
        var service = CreateService(gateway);

        var result = await service.GetCronRunsAsync(
            "job-1",
            limit: 1,
            runId: "run-1");

        var run = Assert.Single(result.Items);
        Assert.DoesNotContain("12345678", run.Summary);
        Assert.DoesNotContain("example.invalid", run.DeliveryError);
        Assert.NotNull(result.NextCursor);
        var invocation = Assert.Single(gateway.Invocations);
        Assert.Equal("job", invocation.Parameters?["scope"]?.GetValue<string>());
        Assert.Equal("job-1", invocation.Parameters?["id"]?.GetValue<string>());
        Assert.Equal("run-1", invocation.Parameters?["runId"]?.GetValue<string>());
    }

    [Fact]
    public async Task CronCreate_BlocksCommandPayloadBeforeGatewayInvocation()
    {
        var gateway = Connected(
            methods: ["cron.add"],
            scopes: ["operator.admin"]);
        var service = CreateService(gateway, allowCommandCron: false);
        var request = new Nexus.Api.Models.CreateOpenClawCronJobRequest(
            "Unsafe",
            JsonNode.Parse("""{ "kind": "every", "everyMs": 60000 }""")!.AsObject(),
            "isolated",
            "now",
            JsonNode.Parse("""{ "kind": "command", "argv": ["whoami"] }""")!.AsObject());

        var result = await service.CreateCronJobAsync(request);

        Assert.False(result.Ok);
        Assert.Equal("restricted", result.State);
        Assert.Equal("cron", result.Operation?.PrimaryRef?.Type);
        Assert.StartsWith("create:", result.Operation?.PrimaryRef?.Id);
        Assert.Empty(gateway.Invocations);
    }

    [Fact]
    public async Task CronCreate_UsesOfficialAddShapeAndReturnsTypedDetail()
    {
        var gateway = Connected(
            methods: ["cron.add"],
            scopes: ["operator.admin"]);
        gateway.Handler = (_, _) => new JsonObject
        {
            ["created"] = true,
            ["job"] = CronJob(name: "Safe reminder", enabled: true)
        };
        var service = CreateService(gateway);
        var request = new Nexus.Api.Models.CreateOpenClawCronJobRequest(
            "Safe reminder",
            JsonNode.Parse("""{ "kind": "every", "everyMs": 60000 }""")!.AsObject(),
            "main",
            "now",
            JsonNode.Parse("""{ "kind": "systemEvent", "text": "Check status" }""")!.AsObject(),
            DeclarationKey: "nexus.safe-reminder");

        var result = await service.CreateCronJobAsync(request);

        Assert.True(result.Ok);
        Assert.Equal("job-1", result.Data?.Id);
        Assert.Equal("cron", result.Operation?.PrimaryRef?.Type);
        Assert.Equal("job-1", result.Operation?.PrimaryRef?.Id);
        var invocation = Assert.Single(gateway.Invocations);
        Assert.Equal("cron.add", invocation.Method);
        Assert.Equal("Safe reminder", invocation.Parameters?["name"]?.GetValue<string>());
        Assert.Equal("every", invocation.Parameters?["schedule"]?["kind"]?.GetValue<string>());
        Assert.Equal("systemEvent", invocation.Parameters?["payload"]?["kind"]?.GetValue<string>());
        Assert.Equal(
            "nexus.safe-reminder",
            invocation.Parameters?["declarationKey"]?.GetValue<string>());
    }

    [Fact]
    public async Task CronPatch_RejectsStaleHashWithoutMutatingGateway()
    {
        var gateway = Connected(
            methods: ["cron.get", "cron.update"],
            scopes: ["operator.read", "operator.admin"]);
        gateway.Handler = (method, _) => method == "cron.get"
            ? CronJob(name: "Morning brief", enabled: true)
            : throw new InvalidOperationException("stale writes must not reach cron.update");
        var service = CreateService(gateway);
        var patch = JsonNode.Parse("""{ "enabled": false }""")!.AsObject();

        var result = await service.PatchCronJobAsync(
            "job-1",
            patch,
            expectedHash: "stale");

        Assert.False(result.Ok);
        Assert.Equal("conflict", result.State);
        Assert.Single(gateway.Invocations);
        Assert.Equal("cron.get", gateway.Invocations[0].Method);
    }

    [Fact]
    public async Task CronPatch_UsesOfficialPatchShapeAfterHashCheck()
    {
        var gateway = Connected(
            methods: ["cron.get", "cron.update"],
            scopes: ["operator.read", "operator.admin"]);
        gateway.Handler = (method, parameters) => method switch
        {
            "cron.get" => CronJob(name: "Morning brief", enabled: true),
            "cron.update" => CronJob(
                name: "Morning brief",
                enabled: parameters?["patch"]?["enabled"]?.GetValue<bool>() ?? true),
            _ => null
        };
        var service = CreateService(gateway);
        var detail = await service.GetCronJobAsync("job-1");

        var result = await service.PatchCronJobAsync(
            "job-1",
            JsonNode.Parse("""{ "enabled": false }""")!.AsObject(),
            detail.Data!.ResourceHash);

        Assert.True(result.Ok);
        Assert.False(result.Data!.Enabled);
        Assert.Equal("cron.update", gateway.Invocations.Last().Method);
        Assert.Equal("job-1", gateway.Invocations.Last().Parameters?["id"]?.GetValue<string>());
        Assert.Equal(
            false,
            gateway.Invocations.Last().Parameters?["patch"]?["enabled"]?.GetValue<bool>());
    }

    [Fact]
    public async Task CronDelete_ChecksHashAndUsesOfficialRemoveShape()
    {
        var gateway = Connected(
            methods: ["cron.get", "cron.remove"],
            scopes: ["operator.read", "operator.admin"]);
        gateway.Handler = (method, _) => method switch
        {
            "cron.get" => CronJob(name: "Morning brief", enabled: true),
            "cron.remove" => JsonNode.Parse("""{ "removed": true }"""),
            _ => null
        };
        var service = CreateService(gateway);
        var detail = await service.GetCronJobAsync("job-1");

        var result = await service.DeleteCronJobAsync(
            "job-1",
            detail.Data!.ResourceHash);

        Assert.True(result.Ok);
        Assert.Equal("cron.remove", gateway.Invocations.Last().Method);
        Assert.Equal("job-1", gateway.Invocations.Last().Parameters?["id"]?.GetValue<string>());
    }

    [Fact]
    public async Task CronMutation_RequiresLocalManagementGate()
    {
        var gateway = Connected(
            methods: ["cron.run"],
            scopes: ["operator.admin"]);
        var service = CreateService(gateway, managementEnabled: false);

        var result = await service.RunCronJobAsync("job-1");

        Assert.False(result.Ok);
        Assert.Equal("management_disabled", result.State);
        Assert.Equal("cron", result.Operation?.PrimaryRef?.Type);
        Assert.Equal("job-1", result.Operation?.PrimaryRef?.Id);
        Assert.Empty(gateway.Invocations);
    }

    [Fact]
    public void Capabilities_DistinguishMissingMethodFromMissingScope()
    {
        var gateway = Connected(
            methods: ["tasks.list", "tasks.cancel"],
            scopes: ["operator.read"]);
        var service = CreateService(gateway);

        var capabilities = service.GetCapabilities();

        Assert.Equal("ready", capabilities.Single(item => item.Id == "tasks-read").State);
        Assert.Equal("forbidden", capabilities.Single(item => item.Id == "tasks-cancel").State);
        Assert.Equal("unsupported", capabilities.Single(item => item.Id == "sessions-read").State);
    }

    [Fact]
    public async Task CancelTask_UsesDocumentedTaskIdAndReason()
    {
        var gateway = Connected(
            methods: ["tasks.cancel"],
            scopes: ["operator.write"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """
            {
              "found": true,
              "cancelled": true,
              "task": { "id": "task-9", "title": "Long run", "status": "cancelled" }
            }
            """);
        var service = CreateService(gateway);

        var result = await service.CancelTaskAsync("task-9", "Operator stop");

        Assert.True(result.Ok);
        Assert.Equal("openclaw-task", result.Operation?.PrimaryRef?.Type);
        Assert.Equal("task-9", result.Operation?.PrimaryRef?.Id);
        var invocation = Assert.Single(gateway.Invocations);
        Assert.Equal("tasks.cancel", invocation.Method);
        Assert.Equal("task-9", invocation.Parameters?["taskId"]?.GetValue<string>());
        Assert.Equal("Operator stop", invocation.Parameters?["reason"]?.GetValue<string>());
    }

    [Fact]
    public async Task ResolveApproval_RejectsUnknownDecisionBeforeGatewayCall()
    {
        var gateway = Connected(
            methods: ["approval.resolve"],
            scopes: ["operator.approvals"]);
        var service = CreateService(gateway);

        var result = await service.ResolveApprovalAsync("approval-1", "exec", "approve");

        Assert.False(result.Ok);
        Assert.Equal("invalid", result.State);
        Assert.Equal("approval", result.Operation?.PrimaryRef?.Type);
        Assert.Equal("approval-1", result.Operation?.PrimaryRef?.Id);
        Assert.Empty(gateway.Invocations);
    }

    [Fact]
    public async Task PatchSessionModel_UsesDocumentedSessionKeyAndModel()
    {
        var gateway = Connected(
            methods: ["sessions.patch"],
            scopes: ["operator.write"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """{ "model": "openai/gpt-5.4", "provider": "openai" }""");
        var service = CreateService(gateway);

        var result = await service.PatchSessionModelAsync(
            "agent:iris:main",
            "openai/gpt-5.4");

        Assert.True(result.Ok);
        var invocation = Assert.Single(gateway.Invocations);
        Assert.Equal("sessions.patch", invocation.Method);
        Assert.Equal("agent:iris:main", invocation.Parameters?["key"]?.GetValue<string>());
        Assert.Equal("openai/gpt-5.4", invocation.Parameters?["model"]?.GetValue<string>());
    }

    [Fact]
    public async Task ModelAuthStatus_AggregatesProfilesAndDropsSensitiveGatewayFields()
    {
        var gateway = Connected(
            methods: ["models.authStatus"],
            scopes: ["operator.read"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """
            {
              "ts": 1785400000000,
              "providers": [{
                "provider": "openai-codex",
                "displayName": "OpenAI Codex",
                "status": "expiring",
                "expiry": {
                  "at": 1785486400000,
                  "remainingMs": 86400000,
                  "label": "untrusted gateway label"
                },
                "profiles": [
                  {
                    "profileId": "bao@example.test",
                    "type": "oauth",
                    "status": "ok",
                    "accessToken": "secret-oauth-token"
                  },
                  {
                    "profileId": "second-private-profile",
                    "type": "oauth",
                    "status": "ok"
                  },
                  {
                    "profileId": "legacy-token-profile",
                    "type": "token",
                    "status": "expired"
                  }
                ],
                "apiKey": {
                  "source": "env",
                  "envVar": "OPENAI_API_KEY",
                  "value": "secret-api-key"
                },
                "usage": {
                  "providerId": "openai",
                  "summary": "82% remaining",
                  "plan": "team",
                  "accountEmail": "billing@example.test",
                  "billing": [{ "amount": 99, "currency": "EUR" }],
                  "windows": [{ "label": "5h", "usedPercent": 18 }]
                }
              }]
            }
            """);
        var service = CreateService(gateway);

        var result = await service.GetModelAuthStatusAsync(refresh: true);

        Assert.Equal("ready", result.State);
        var provider = Assert.Single(result.Items);
        Assert.Equal("openai-codex", provider.Provider);
        Assert.Equal("OpenAI Codex", provider.DisplayName);
        Assert.Equal("expiring", provider.Status);
        Assert.Equal("1d", provider.Expiry?.Label);
        Assert.Equal("env", provider.ApiKey?.Source);
        Assert.Equal("OPENAI_API_KEY", provider.ApiKey?.EnvVar);
        Assert.Equal("82% remaining", provider.Usage?.Summary);
        Assert.Equal("team", provider.Usage?.Plan);

        var oauth = Assert.Single(
            provider.Profiles,
            profile => profile.Type == "oauth" && profile.Status == "ok");
        Assert.Equal(2, oauth.Count);
        var token = Assert.Single(
            provider.Profiles,
            profile => profile.Type == "token" && profile.Status == "expired");
        Assert.Equal(1, token.Count);

        var serialized = JsonSerializer.Serialize(provider);
        Assert.DoesNotContain("profileId", serialized, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("bao@example.test", serialized, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("billing", serialized, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("secret", serialized, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("windows", serialized, StringComparison.OrdinalIgnoreCase);

        var invocation = Assert.Single(gateway.Invocations);
        Assert.Equal("models.authStatus", invocation.Method);
        Assert.True(invocation.Parameters?["refresh"]?.GetValue<bool>());
    }

    [Fact]
    public async Task ModelAuthStatus_RejectsUnsafeMetadataAndReportsUnsupported()
    {
        var gateway = Connected(
            methods: ["models.authStatus"],
            scopes: ["operator.read"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """
            {
              "providers": [{
                "provider": "custom",
                "displayName": "Custom",
                "status": "future-state",
                "profiles": [{ "type": "future-profile", "status": "future-state" }],
                "apiKey": { "source": "env", "envVar": "bad env name" },
                "usage": {
                  "summary": "details at billing@example.test",
                  "plan": "https://provider.example/private"
                }
              }]
            }
            """);
        var service = CreateService(gateway);

        var result = await service.GetModelAuthStatusAsync();

        var provider = Assert.Single(result.Items);
        Assert.Equal("unknown", provider.Status);
        var profile = Assert.Single(provider.Profiles);
        Assert.Equal("unknown", profile.Type);
        Assert.Equal("unknown", profile.Status);
        Assert.Null(provider.ApiKey?.EnvVar);
        Assert.Null(provider.Usage);

        gateway.AdvertisedMethods = new HashSet<string>(StringComparer.Ordinal);
        var unsupported = await service.GetModelAuthStatusAsync();
        Assert.Equal("unsupported", unsupported.State);
        Assert.Empty(unsupported.Items);
    }

    [Fact]
    public async Task RunCronJob_RequiresAdminAndUsesForceMode()
    {
        var gateway = Connected(
            methods: ["cron.run"],
            scopes: ["operator.admin"]);
        gateway.Handler = (_, _) => JsonNode.Parse(
            """{ "enqueued": true, "runId": "run-17" }""");
        var service = CreateService(gateway);

        var result = await service.RunCronJobAsync("job-17");

        Assert.True(result.Ok);
        var invocation = Assert.Single(gateway.Invocations);
        Assert.Equal("cron.run", invocation.Method);
        Assert.Equal("job-17", invocation.Parameters?["id"]?.GetValue<string>());
        Assert.Equal("force", invocation.Parameters?["mode"]?.GetValue<string>());
    }

    [Fact]
    public async Task MutationContext_DeduplicatesRetryAndDoesNotAddUndocumentedGatewayField()
    {
        var testRoot = Path.Combine(
            Path.GetTempPath(),
            "nexus-openclaw-control-tests",
            Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(testRoot);
        try
        {
            var gateway = Connected(
                methods: ["tasks.cancel"],
                scopes: ["operator.write"]);
            gateway.Handler = (_, _) => JsonNode.Parse(
                """
                {
                  "found": true,
                  "cancelled": true,
                  "task": { "id": "task-9", "title": "Long run", "status": "cancelled" }
                }
                """);
            var audit = new OpenClawOperationAuditStore(
                Options.Create(new GatewayConnectorOptions
                {
                    DeviceStatePath = Path.Combine(testRoot, "device.json"),
                    OperationAuditPath = Path.Combine(testRoot, "operations.jsonl")
                }));
            var service = CreateService(gateway, audit);
            var context = OpenClawInvocationContext.Create(
                actor: "owner-1",
                idempotencyKey: "idem-task-9",
                correlationId: "corr-task-9",
                traceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
                includeIdempotencyParameter: true);

            var first = await service.CancelTaskAsync(
                "task-9",
                "Operator stop",
                invocationContext: context);
            var retry = await service.CancelTaskAsync(
                "task-9",
                "Operator stop",
                invocationContext: context);
            var conflict = await service.CancelTaskAsync(
                "task-9",
                "Different reason",
                invocationContext: context);

            Assert.True(first.Ok);
            Assert.Equal("replayed", retry.State);
            Assert.Equal("idempotency_conflict", conflict.State);
            var invocation = Assert.Single(gateway.Invocations);
            Assert.Equal(context.TraceParent, invocation.Context?.TraceParent);
            Assert.Equal(context.CorrelationId, first.CorrelationId);
            Assert.Equal(context.IdempotencyKey, first.IdempotencyKey);
            Assert.Equal(context.CorrelationId, first.Operation?.OperationId);
            Assert.Equal("openclaw-task", retry.Operation?.PrimaryRef?.Type);
            Assert.Equal("task-9", conflict.Operation?.PrimaryRef?.Id);
            Assert.Null(invocation.Parameters?["idempotencyKey"]);
        }
        finally
        {
            var safeRoot = Path.GetFullPath(Path.Combine(
                Path.GetTempPath(),
                "nexus-openclaw-control-tests"));
            var resolved = Path.GetFullPath(testRoot);
            if (resolved.StartsWith(safeRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
                Directory.Exists(resolved))
            {
                Directory.Delete(resolved, recursive: true);
            }
        }
    }

    private static OpenClawControlService CreateService(
        StubOpenClawConnector gateway,
        IOpenClawOperationAuditStore? auditStore = null,
        bool managementEnabled = true,
        bool allowCommandCron = true)
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Integrations:OpenClaw:BaseUrl"] = "http://127.0.0.1:18789",
                ["Integrations:OpenClaw:Token"] = "test-only-token",
                ["Integrations:OpenClaw:ManagementEnabled"] =
                    managementEnabled.ToString(CultureInfo.InvariantCulture),
                ["Integrations:OpenClaw:AllowCommandCron"] =
                    allowCommandCron.ToString(CultureInfo.InvariantCulture)
            })
            .Build();
        return new OpenClawControlService(
            gateway,
            configuration,
            new StubOpenClawWriteGate(
                managementEnabled
                    ? OpenClawWriteGateDecision.Permit()
                    : OpenClawWriteGateDecision.Block(
                        "management_disabled",
                        "OpenClaw management is disabled.")),
            NullLogger<OpenClawControlService>.Instance,
            operationAuditStore: auditStore);
    }

    private static JsonNode CronJob(
        string name,
        bool enabled,
        string? deliveryTarget = null)
    {
        var job = JsonNode.Parse(
            $$"""
            {
              "id": "job-1",
              "name": {{JsonSerializer.Serialize(name)}},
              "enabled": {{enabled.ToString().ToLowerInvariant()}},
              "createdAtMs": 1784000000000,
              "updatedAtMs": 1785000000000,
              "schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "Europe/Berlin" },
              "sessionTarget": "isolated",
              "wakeMode": "now",
              "payload": { "kind": "agentTurn", "message": "Prepare brief" },
              "state": { "nextRunAtMs": 1786000000000 }
            }
            """)!.AsObject();
        if (deliveryTarget is not null)
        {
            job["delivery"] = new JsonObject
            {
                ["mode"] = "announce",
                ["channel"] = "telegram",
                ["to"] = deliveryTarget
            };
        }

        return job;
    }

    private static StubOpenClawConnector Connected(
        IEnumerable<string> methods,
        IEnumerable<string> scopes)
    {
        return new StubOpenClawConnector
        {
            ConnectionState = GatewayConnectionState.Connected,
            GatewayVersion = "2026.7.1",
            ProtocolVersion = 4,
            AdvertisedMethods = methods.ToHashSet(StringComparer.Ordinal),
            GrantedScopes = scopes.ToHashSet(StringComparer.Ordinal)
        };
    }
}

public sealed class StubOpenClawConnector : IGatewayConnector
{
    public GatewayConnectionState ConnectionState { get; set; } = GatewayConnectionState.Initializing;
    public string? GatewayVersion { get; set; }
    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 string? ActiveEndpoint { get; set; }
    public string? ActiveTlsFingerprint { get; set; }
    public bool DeviceTokenConfigured { get; set; }
    public bool PairingRequired { get; set; }
    public string? PairingRequestId { get; set; }
    public int? ProtocolVersion { get; set; }
    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<(string Method, JsonNode? Parameters, OpenClawInvocationContext? Context)> 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(),
            _ => JsonSerializer.SerializeToNode(parameters)
        };
        Invocations.Add((method, node, invocationContext));
        return Task.FromResult(Handler?.Invoke(method, node));
    }

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