using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;

namespace Nexus.Api.Tests;

public sealed class AgentProposalServiceTests
{
    [Fact]
    public async Task Create_IsIdempotent_AndNeverMutatesOpenClaw()
    {
        await using var fixture = await AgentProposalFixture.CreateAsync(
            externalIdentitySupported: false);
        var request = Proposal("Release Analyst");
        var invocation = Invocation("proposal-1");

        var first = await fixture.Service.CreateAsync(
            request,
            "manual",
            invocation);
        var replay = await fixture.Service.CreateAsync(
            request,
            "manual",
            invocation);

        Assert.True(first.Ok);
        Assert.Equal(AgentProposalStates.AwaitingApproval, first.State);
        Assert.True(replay.Ok);
        Assert.Equal("idempotent_replay", replay.State);
        Assert.Equal(first.Proposal!.Id, replay.Proposal!.Id);
        Assert.Equal(1, await fixture.Db.AgentProposals.CountAsync());
        Assert.Equal(0, fixture.Gateway.CreateCalls);
    }

    [Fact]
    public async Task ListCursor_DoesNotSkipProposalsWithTheSameCreatedAt()
    {
        await using var fixture = await AgentProposalFixture.CreateAsync(
            externalIdentitySupported: false);
        foreach (var suffix in new[] { "a", "b", "c" })
        {
            await fixture.Service.CreateAsync(
                Proposal($"Release Analyst {suffix}") with
                {
                    ClientRequestId = $"proposal-{suffix}"
                },
                "manual",
                Invocation($"proposal-{suffix}"));
        }

        var timestamp = new DateTimeOffset(
            2026,
            7,
            30,
            12,
            0,
            0,
            TimeSpan.Zero);
        foreach (var proposal in await fixture.Db.AgentProposals.ToListAsync())
        {
            proposal.CreatedAt = timestamp;
            proposal.UpdatedAt = timestamp;
        }
        await fixture.Db.SaveChangesAsync();

        var first = await fixture.Service.GetAsync(limit: 2);
        var second = await fixture.Service.GetAsync(
            limit: 2,
            cursor: first.NextCursor);

        Assert.Equal(2, first.Items.Count);
        Assert.Single(second.Items);
        Assert.Null(second.NextCursor);
        Assert.Equal(
            3,
            first.Items
                .Concat(second.Items)
                .Select(item => item.Id)
                .Distinct()
                .Count());
    }

    [Fact]
    public async Task Approve_FailsClosed_WhenExternalClientIdentityIsUnsupported()
    {
        await using var fixture = await AgentProposalFixture.CreateAsync(
            externalIdentitySupported: false);
        var created = await fixture.Service.CreateAsync(
            Proposal("Release Analyst"),
            "manual",
            Invocation("proposal-1"));

        var approved = await fixture.Service.ApproveAsync(
            created.Proposal!.Id,
            new AgentProposalActionRequest(created.Proposal.Revision),
            Invocation("approve-1"));

        Assert.False(approved.Ok);
        Assert.Equal("experimental_blocked", approved.State);
        Assert.Equal(0, await fixture.Db.AgentProvisionRequests.CountAsync());
        Assert.Equal(0, fixture.Gateway.CreateCalls);
        Assert.Equal(
            AgentProposalStates.AwaitingApproval,
            (await fixture.Service.GetByIdAsync(created.Proposal.Id))!.Status);
    }

    [Fact]
    public async Task ApprovedProposal_CreatesOnce_VerifiesInventory_AndFinalizesFiles()
    {
        await using var fixture = await AgentProposalFixture.CreateAsync(
            externalIdentitySupported: true);
        var created = await fixture.Service.CreateAsync(
            Proposal("Release Analyst"),
            "manual",
            Invocation("proposal-1"));
        var approved = await fixture.Service.ApproveAsync(
            created.Proposal!.Id,
            new AgentProposalActionRequest(created.Proposal.Revision),
            Invocation("approve-1"));

        Assert.True(approved.Ok);
        Assert.Equal(AgentProposalStates.Provisioning, approved.State);
        Assert.True(await fixture.Service.ProcessNextAsync());

        var completed = await fixture.Service.GetByIdAsync(created.Proposal.Id);
        Assert.NotNull(completed);
        Assert.Equal(AgentProposalStates.Ready, completed!.Status);
        Assert.Equal("release-analyst", completed.OpenClawAgentId);
        Assert.Equal(1, fixture.Gateway.CreateCalls);
        Assert.Contains(
            "release-analyst/IDENTITY.md",
            fixture.AgentFiles.Writes.Keys);
        Assert.Contains(
            "release-analyst/AGENTS.md",
            fixture.AgentFiles.Writes.Keys);
        Assert.Empty(completed.Files);
    }

    [Fact]
    public async Task Provisioning_ResolvesWorkspaceFromLiveOpenClawDefaults()
    {
        await using var fixture = await AgentProposalFixture.CreateAsync(
            externalIdentitySupported: true);
        var created = await fixture.Service.CreateAsync(
            Proposal("Release Analyst"),
            "manual",
            Invocation("proposal-live-workspace"));
        fixture.AgentFiles.DefaultWorkspace = "/srv/openclaw/workspaces";

        await fixture.Service.ApproveAsync(
            created.Proposal!.Id,
            new AgentProposalActionRequest(created.Proposal.Revision),
            Invocation("approve-live-workspace"));
        Assert.True(await fixture.Service.ProcessNextAsync());

        var completed = await fixture.Service.GetByIdAsync(created.Proposal.Id);
        Assert.Equal(
            "/srv/openclaw/workspaces/release-analyst",
            completed!.Workspace);
        Assert.Equal(
            "/srv/openclaw/workspaces/release-analyst",
            completed.OpenClawWorkspace);
        Assert.Equal(1, fixture.Gateway.CreateCalls);
    }

    [Fact]
    public async Task UncertainCreate_IsNotRepeated_AndRetryReconcilesBeforeAnotherCreate()
    {
        await using var fixture = await AgentProposalFixture.CreateAsync(
            externalIdentitySupported: true);
        fixture.Gateway.ThrowUncertainCreate = true;
        var created = await fixture.Service.CreateAsync(
            Proposal("Release Analyst"),
            "manual",
            Invocation("proposal-1"));
        await fixture.Service.ApproveAsync(
            created.Proposal!.Id,
            new AgentProposalActionRequest(created.Proposal.Revision),
            Invocation("approve-1"));

        Assert.True(await fixture.Service.ProcessNextAsync());
        Assert.False(await fixture.Service.ProcessNextAsync());
        var uncertain = await fixture.Service.GetByIdAsync(created.Proposal.Id);
        Assert.Equal(AgentProposalStates.InDoubt, uncertain!.Status);
        Assert.Equal(1, fixture.Gateway.CreateCalls);

        var retry = await fixture.Service.RetryAsync(
            created.Proposal.Id,
            new AgentProposalActionRequest(uncertain.Revision),
            Invocation("retry-1"));
        Assert.True(retry.Ok);
        Assert.True(await fixture.Service.ProcessNextAsync());

        var reconciled = await fixture.Service.GetByIdAsync(created.Proposal.Id);
        Assert.Equal(AgentProposalStates.Failed, reconciled!.Status);
        Assert.Equal("reconciled_absent", reconciled.Error!.Code);
        Assert.Equal(1, fixture.Gateway.CreateCalls);
    }

    [Fact]
    public async Task ExpiredCreateLease_IsReclaimedAsReadOnlyReconciliation()
    {
        await using var fixture = await AgentProposalFixture.CreateAsync(
            externalIdentitySupported: true);
        var created = await fixture.Service.CreateAsync(
            Proposal("Lease Sentinel"),
            "manual",
            Invocation("proposal-lease"));
        await fixture.Service.ApproveAsync(
            created.Proposal!.Id,
            new AgentProposalActionRequest(created.Proposal.Revision),
            Invocation("approve-lease"));

        var request = await fixture.Db.AgentProvisionRequests.SingleAsync();
        request.Status = AgentProvisionRequestStates.Dispatching;
        request.DispatchStartedAt = DateTimeOffset.UtcNow.AddMinutes(-5);
        request.LeaseOwner = "abandoned-worker";
        request.LeaseUntil = DateTimeOffset.UtcNow.AddSeconds(-1);
        await fixture.Db.SaveChangesAsync();

        Assert.True(await fixture.Service.ProcessNextAsync());

        var reconciled = await fixture.Service.GetByIdAsync(
            created.Proposal.Id);
        Assert.Equal(AgentProposalStates.Failed, reconciled!.Status);
        Assert.Equal("reconciled_absent", reconciled.Error!.Code);
        Assert.Equal(0, fixture.Gateway.CreateCalls);
        Assert.Contains(
            await fixture.Db.OutboxEvents.ToListAsync(),
            item => item.Type == "agent.provision.lease_reclaimed");
    }

    [Fact]
    public void Controller_AndMcpTools_ExposeExpectedSecurityMetadata()
    {
        var authorize = typeof(OpenClawAgentProposalsController)
            .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
            .OfType<AuthorizeAttribute>()
            .Single();
        Assert.Equal("owner", authorize.Roles);

        var propose = typeof(NexusMcpTools).GetMethod(
            nameof(NexusMcpTools.ProposeAgent))!;
        var proposeTool = propose.GetCustomAttributes(
                typeof(McpServerToolAttribute),
                inherit: false)
            .OfType<McpServerToolAttribute>()
            .Single();
        Assert.Equal("nexus_propose_agent", proposeTool.Name);
        Assert.False(proposeTool.ReadOnly);
        Assert.False(proposeTool.Destructive);
        Assert.True(proposeTool.Idempotent);
        Assert.False(proposeTool.OpenWorld);
        Assert.True(proposeTool.UseStructuredContent);
        Assert.Equal(typeof(AgentProposalToolResult), proposeTool.OutputSchemaType);

        var get = typeof(NexusMcpTools).GetMethod(
            nameof(NexusMcpTools.GetAgentProposal))!;
        var getTool = get.GetCustomAttributes(
                typeof(McpServerToolAttribute),
                inherit: false)
            .OfType<McpServerToolAttribute>()
            .Single();
        Assert.True(getTool.ReadOnly);
        Assert.False(getTool.Destructive);
    }

    private static CreateAgentProposalRequest Proposal(string name)
        => new(
            name,
            Role: "Release quality",
            Description: "Verify releases and report evidence.",
            Model: "openai/gpt-5.5",
            ClientRequestId: "proposal-1");

    private static OpenClawInvocationMetadata Invocation(string key)
        => new(key, $"correlation-{key}", "bao", null);
}

internal sealed class AgentProposalFixture : IAsyncDisposable
{
    private AgentProposalFixture(
        NexusDbContext db,
        AgentProposalService service,
        ProposalGatewayConnector gateway,
        ProposalAgentConfigurationService agentFiles)
    {
        Db = db;
        Service = service;
        Gateway = gateway;
        AgentFiles = agentFiles;
    }

    public NexusDbContext Db { get; }
    public AgentProposalService Service { get; }
    public ProposalGatewayConnector Gateway { get; }
    public ProposalAgentConfigurationService AgentFiles { get; }

    public static async Task<AgentProposalFixture> CreateAsync(
        bool externalIdentitySupported)
    {
        var options = new DbContextOptionsBuilder<NexusDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;
        var db = new NexusDbContext(options);
        await db.Database.EnsureCreatedAsync();

        var gateway = new ProposalGatewayConnector();
        var management = new OpenClawManagementState();
        management.SetEnabled(true);
        db.OpenClawConnectionProfiles.Add(new OpenClawConnectionProfile
        {
            Endpoint = "ws://127.0.0.1:18789/",
            DiscoverySource = "test",
            RequiredVersion = gateway.RequiredVersion,
            AdoptionState = OpenClawAdoptionStates.Adopted,
            ManagementEnabled = true,
            CapabilityHash = AgentProposalService.BuildCapabilityHash(gateway),
            Revision = 1
        });
        await db.SaveChangesAsync();

        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["OpenClawSetup:ExternalClientIdentitySupported"] =
                    externalIdentitySupported.ToString()
            })
            .Build();
        var agentFiles = new ProposalAgentConfigurationService();
        var service = new AgentProposalService(
            db,
            gateway,
            agentFiles,
            new StubOpenClawWriteGate(
                externalIdentitySupported
                    ? OpenClawWriteGateDecision.Permit()
                    : OpenClawWriteGateDecision.Block(
                        "experimental_blocked",
                        "External identity is not supported.")),
            Options.Create(new AgentProvisioningOptions()),
            new AgentProvisioningSignal(),
            NullLogger<AgentProposalService>.Instance);
        return new AgentProposalFixture(db, service, gateway, agentFiles);
    }

    public ValueTask DisposeAsync() => Db.DisposeAsync();
}

internal sealed class ProposalGatewayConnector : IGatewayConnector
{
    private readonly List<(string Id, string Workspace)> agents = [];

    public bool ThrowUncertainCreate { get; set; }
    public int CreateCalls { get; private set; }
    public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;
    public string? GatewayVersion => "2026.7.1";
    public string? RequiredVersion => "2026.7.1";
    public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow;
    public int ReconnectAttempts => 0;
    public string? StatusMessage => "ready";
    public string? DeviceId => "test-device";
    public bool DeviceTokenConfigured => true;
    public bool PairingRequired => false;
    public string? PairingRequestId => null;
    public int? ProtocolVersion => 4;
    public IReadOnlySet<string> AdvertisedMethods { get; } = new HashSet<string>(
        [
            "agents.list",
            "agents.create",
            "agents.files.get",
            "agents.files.set",
            "config.get",
            "models.list"
        ],
        StringComparer.Ordinal);
    public IReadOnlySet<string> AdvertisedEvents { get; } =
        new HashSet<string>(StringComparer.Ordinal);
    public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>(
        ["operator.read", "operator.admin"],
        StringComparer.Ordinal);
    public DateTimeOffset? LastEventAt => null;

    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)
    {
        if (method == "agents.list")
        {
            return Task.FromResult<JsonNode?>(new JsonObject
            {
                ["agents"] = new JsonArray(agents
                    .Select(item => (JsonNode)new JsonObject
                    {
                        ["id"] = item.Id,
                        ["workspace"] = item.Workspace
                    })
                    .ToArray())
            });
        }

        if (method == "models.list")
        {
            return Task.FromResult<JsonNode?>(new JsonObject
            {
                ["models"] = new JsonArray
                {
                    new JsonObject
                    {
                        ["id"] = "openai/gpt-5.5",
                        ["name"] = "GPT-5.5",
                        ["provider"] = "openai",
                        ["available"] = true
                    }
                }
            });
        }

        if (method == "agents.create")
        {
            CreateCalls++;
            if (ThrowUncertainCreate)
            {
                throw new OpenClawGatewayRpcException(
                    "GATEWAY_DISCONNECTED",
                    "Connection dropped.",
                    retryable: true);
            }

            var json = JsonSerializer.SerializeToNode(parameters)!.AsObject();
            var name = json["name"]!.GetValue<string>();
            var id = name.Trim().ToLowerInvariant().Replace(' ', '-');
            var workspace = json["workspace"]!.GetValue<string>();
            agents.Add((id, workspace));
            return Task.FromResult<JsonNode?>(new JsonObject
            {
                ["ok"] = true,
                ["agentId"] = id,
                ["name"] = name,
                ["workspace"] = workspace
            });
        }

        throw new OpenClawGatewayRpcException(
            "METHOD_NOT_FOUND",
            $"Unexpected method {method}.");
    }

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

internal sealed class ProposalAgentConfigurationService
    : IOpenClawAgentConfigurationService
{
    public Dictionary<string, string> Writes { get; } =
        new(StringComparer.Ordinal);
    public string? DefaultWorkspace { get; set; }

    public Task<OpenClawAgentFileDto> GetAgentFileAsync(
        string agentId,
        string fileName,
        CancellationToken cancellationToken = default)
    {
        var key = $"{agentId}/{fileName}";
        if (Writes.TryGetValue(key, out var content))
        {
            return Task.FromResult(File(agentId, fileName, content));
        }

        return Task.FromResult(new OpenClawAgentFileDto(
            agentId,
            fileName,
            true,
            null,
            null,
            null,
            OpenClawAgentConfigurationService.MissingContentHash,
            DateTimeOffset.UtcNow));
    }

    public async Task<OpenClawAgentFileWriteDto> SetAgentFileAsync(
        string agentId,
        string fileName,
        UpdateOpenClawAgentFileRequest request,
        OpenClawInvocationContext invocationContext,
        CancellationToken cancellationToken = default)
    {
        Writes[$"{agentId}/{fileName}"] = request.Content;
        var file = await GetAgentFileAsync(agentId, fileName, cancellationToken);
        return new OpenClawAgentFileWriteDto(
            true,
            "completed",
            "verified",
            file,
            true,
            invocationContext.IdempotencyKey,
            invocationContext.CorrelationId,
            DateTimeOffset.UtcNow);
    }

    public Task<OpenClawAgentFileCollectionDto> GetAgentFilesAsync(
        string agentId,
        CancellationToken cancellationToken = default)
        => throw new NotSupportedException();

    public Task<OpenClawWorkspaceCollectionDto> GetWorkspaceAsync(
        string agentId,
        string? path,
        int offset,
        int limit,
        CancellationToken cancellationToken = default)
        => throw new NotSupportedException();

    public Task<OpenClawWorkspaceFileDto> GetWorkspaceFileAsync(
        string agentId,
        string path,
        CancellationToken cancellationToken = default)
        => throw new NotSupportedException();

    public Task<OpenClawConfigSchemaLookupDto> GetConfigSchemaAsync(
        string path,
        CancellationToken cancellationToken = default)
        => throw new NotSupportedException();

    public Task<OpenClawConfigSnapshotDto> GetConfigAsync(
        CancellationToken cancellationToken = default)
        => Task.FromResult(new OpenClawConfigSnapshotDto(
            true,
            true,
            "config-hash",
            new JsonObject
            {
                ["agents"] = new JsonObject
                {
                    ["defaults"] = new JsonObject
                    {
                        ["workspace"] = DefaultWorkspace
                    }
                }
            },
            null,
            null,
            DateTimeOffset.UtcNow));

    public Task<OpenClawConfigPatchDto> PatchConfigAsync(
        PatchOpenClawConfigRequest request,
        OpenClawInvocationContext invocationContext,
        CancellationToken cancellationToken = default)
        => throw new NotSupportedException();

    private static OpenClawAgentFileDto File(
        string agentId,
        string name,
        string content)
        => new(
            agentId,
            name,
            false,
            Encoding.UTF8.GetByteCount(content),
            DateTimeOffset.UtcNow,
            content,
            Convert.ToHexString(
                    SHA256.HashData(Encoding.UTF8.GetBytes(content)))
                .ToLowerInvariant(),
            DateTimeOffset.UtcNow);
}
