// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.Agents.AI.UnitTests.AgentSkills;

/// <summary>
/// Unit tests for <see cref="AgentClassSkill{TSelf}"/> and <see cref="AgentInMemorySkillsSource"/>.
/// </summary>
public sealed class AgentClassSkillTests
{
    [Fact]
    public async Task MinimalClassSkill_HasNullOverrides_AndSynthesizesContentAsync()
    {
        // Arrange
        var skill = new MinimalClassSkill();

        // Act & Assert — null overrides
        Assert.Equal("minimal", skill.Frontmatter.Name);
        Assert.Null(skill.Resources);

        // Act & Assert — synthesized XML content
        Assert.Contains("<name>minimal</name>", await skill.GetContentAsync());
        Assert.Contains("<description>A minimal skill.</description>", await skill.GetContentAsync());
        Assert.Contains("<instructions>", await skill.GetContentAsync());
        Assert.Contains("Minimal skill body.", await skill.GetContentAsync());
        Assert.Contains("</instructions>", await skill.GetContentAsync());
    }

    [Fact]
    public async Task FullClassSkill_ReturnsOverriddenLists_AndCachesContentAsync()
    {
        // Arrange
        var skill = new FullClassSkill();

        // Act & Assert — overridden resources and scripts
        Assert.Single(skill.Resources!);
        Assert.Equal("test-resource", skill.Resources![0].Name);

        Assert.Single(skill.Scripts!);
        Assert.Equal("TestScript", skill.Scripts![0].Name);

        // Act & Assert — Content is cached
        Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());

        // Act & Assert — Content includes parameter schema from typed script (with preserved quotes)
        Assert.Contains("\"value\"", await skill.GetContentAsync());
    }

    [Fact]
    public void ResourcesAndScripts_CanBeLazyLoaded_AndCached()
    {
        // Arrange
        var skill = new LazyLoadedSkill();

        // Act & Assert
        Assert.Equal(0, skill.ResourceCreationCount);
        Assert.Equal(0, skill.ScriptCreationCount);

        var firstResources = skill.Resources;
        var firstScripts = skill.Scripts;
        var secondResources = skill.Resources;
        var secondScripts = skill.Scripts;

        Assert.Single(firstResources!);
        Assert.Single(firstScripts!);
        Assert.Same(firstResources, secondResources);
        Assert.Same(firstScripts, secondScripts);
        Assert.Equal(1, skill.ResourceCreationCount);
        Assert.Equal(1, skill.ScriptCreationCount);
    }

    [Fact]
    public async Task AgentInMemorySkillsSource_ReturnsAllSkillsAsync()
    {
        // Arrange
        var skills = new AgentSkill[] { new MinimalClassSkill(), new FullClassSkill() };
        var source = new AgentInMemorySkillsSource(skills);

        // Act
        var result = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(), CancellationToken.None);

        // Assert
        Assert.Equal(2, result.Count);
        Assert.Equal("minimal", result[0].Frontmatter.Name);
        Assert.Equal("full", result[1].Frontmatter.Name);
    }

    [Fact]
    public void AgentClassSkill_InvalidFrontmatter_ThrowsArgumentException()
    {
        // Act & Assert
        Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter("INVALID-NAME", "An invalid skill."));
    }

    [Fact]
    public void PartialOverrides_OneCollectionNull_OtherHasValues()
    {
        // Arrange
        var resourceOnly = new ResourceOnlySkill();
        var scriptOnly = new ScriptOnlySkill();

        // Act & Assert
        Assert.Single(resourceOnly.Resources!);
        Assert.Null(resourceOnly.Scripts);
        Assert.Null(scriptOnly.Resources);
        Assert.Single(scriptOnly.Scripts!);
    }

    [Fact]
    public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
    {
        // Arrange
        var skill = new FullClassSkill();

        // Act
        var resource = await skill.GetResourceAsync("test-resource");

        // Assert
        Assert.NotNull(resource);
        Assert.Equal("test-resource", resource!.Name);
    }

    [Fact]
    public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
    {
        // Arrange
        var skill = new FullClassSkill();

        // Act
        var resource = await skill.GetResourceAsync("missing");

        // Assert
        Assert.Null(resource);
    }

    [Fact]
    public async Task GetResourceAsync_NoResources_ReturnsNullAsync()
    {
        // Arrange
        var skill = new MinimalClassSkill();

        // Act
        var resource = await skill.GetResourceAsync("anything");

        // Assert
        Assert.Null(resource);
    }

    [Fact]
    public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
    {
        // Arrange
        var skill = new FullClassSkill();

        // Act
        var script = await skill.GetScriptAsync("TestScript");

        // Assert
        Assert.NotNull(script);
        Assert.Equal("TestScript", script!.Name);
    }

    [Fact]
    public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
    {
        // Arrange
        var skill = new FullClassSkill();

        // Act
        var script = await skill.GetScriptAsync("missing");

        // Assert
        Assert.Null(script);
    }

    [Fact]
    public async Task GetScriptAsync_NoScripts_ReturnsNullAsync()
    {
        // Arrange
        var skill = new MinimalClassSkill();

        // Act
        var script = await skill.GetScriptAsync("anything");

        // Assert
        Assert.Null(script);
    }

    [Fact]
    public async Task ConcurrentAccess_ToReflectedResourcesScriptsAndContent_InvokesDiscoveryOnceAsync()
    {
        // Regression test for thread-safety of Lazy<T> initialization in AgentClassSkill<TSelf>.
        // AttributedFullSkill uses attribute-based discovery (no override), so it exercises
        // the base class's Lazy<T> fields rather than a subclass's own caching.
        var skill = new AttributedFullSkill();

        const int Concurrency = 32;
        var resourcesResults = new IReadOnlyList<AgentSkillResource>?[Concurrency];
        var scriptsResults = new IReadOnlyList<AgentSkillScript>?[Concurrency];
        var contentResults = new string[Concurrency];

        // Act — invoke all three accessors concurrently from many threads.
        await Task.WhenAll(Enumerable.Range(0, Concurrency).Select(i => Task.Run(async () =>
        {
            resourcesResults[i] = skill.Resources;
            scriptsResults[i] = skill.Scripts;
            contentResults[i] = await skill.GetContentAsync();
        })));

        // Assert — every thread observed the same cached instances (no torn state).
        for (int i = 1; i < Concurrency; i++)
        {
            Assert.Same(resourcesResults[0], resourcesResults[i]);
            Assert.Same(scriptsResults[0], scriptsResults[i]);
            Assert.Same(contentResults[0], contentResults[i]);
        }
    }

    [Fact]
    public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomTypesAsync()
    {
        // Arrange
        var skill = new CustomTypeSkill();
        var jso = SkillTestJsonContext.Default.Options;

        // Act — script with custom type deserialization
        var script = skill.Scripts![0];
        var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso);
        using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
        var args = argsDoc.RootElement;
        var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None);

        // Assert
        Assert.NotNull(scriptResult);
        var resultText = scriptResult!.ToString()!;
        Assert.Contains("result for test", resultText);
        Assert.Contains("5", resultText);

        // Act — resource with custom type serialization
        var resourceResult = await skill.Resources![0].ReadAsync();

        // Assert
        Assert.NotNull(resourceResult);
        Assert.Contains("dark", resourceResult!.ToString()!);
    }

    [Fact]
    public void Scripts_DiscoveredViaAttribute_WithCorrectNamesAndDescriptions()
    {
        // Arrange
        var skill = new AttributedScriptsSkill();

        // Act & Assert — all scripts discovered with correct metadata
        Assert.NotNull(skill.Scripts);
        Assert.Equal(4, skill.Scripts!.Count);
        Assert.Contains(skill.Scripts, s => s.Name == "do-work");
        Assert.Contains(skill.Scripts, s => s.Name == "DefaultNamed");
        Assert.Contains(skill.Scripts, s => s.Name == "append");

        var processScript = skill.Scripts.First(s => s.Name == "process");
        Assert.Equal("Processes the input.", processScript.Description);
    }

    [Fact]
    public async Task Scripts_DiscoveredViaAttribute_StaticAndInstance_CanBeInvokedAsync()
    {
        // Arrange
        var skill = new AttributedScriptsSkill();

        // Act & Assert — static method
        var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work");
        using var doWorkDoc = JsonDocument.Parse("""{"input":"hello"}""");
        var doWorkResult = await doWorkScript.RunAsync(skill, doWorkDoc.RootElement, null, CancellationToken.None);
        Assert.Equal("HELLO", doWorkResult?.ToString());

        // Act & Assert — instance method
        var appendScript = skill.Scripts!.First(s => s.Name == "append");
        using var appendDoc = JsonDocument.Parse("""{"input":"test"}""");
        var appendResult = await appendScript.RunAsync(skill, appendDoc.RootElement, null, CancellationToken.None);
        Assert.Equal("test-suffix", appendResult?.ToString());
    }

    [Fact]
    public void Resources_DiscoveredViaAttribute_OnProperties_WithCorrectMetadata()
    {
        // Arrange
        var skill = new AttributedResourcePropertiesSkill();

        // Act
        var resources = skill.Resources;

        // Assert — all resources discovered with correct metadata
        Assert.NotNull(resources);
        Assert.Equal(4, resources!.Count);
        Assert.Contains(resources, r => r.Name == "ref-data");
        Assert.Contains(resources, r => r.Name == "DefaultNamed");
        Assert.Contains(resources, r => r.Name == "static-data");

        var describedResource = resources.First(r => r.Name == "data");
        Assert.Equal("Some important data.", describedResource.Description);
    }

    [Fact]
    public async Task Resources_DiscoveredViaAttribute_OnProperties_CanBeReadAsync()
    {
        // Arrange
        var skill = new AttributedResourcePropertiesSkill();

        // Act & Assert — instance property
        var refData = skill.Resources!.First(r => r.Name == "ref-data");
        Assert.Equal("Reference content.", (await refData.ReadAsync())?.ToString());

        // Act & Assert — static property
        var staticData = skill.Resources!.First(r => r.Name == "static-data");
        Assert.Equal("Static content.", (await staticData.ReadAsync())?.ToString());
    }

    [Fact]
    public async Task Resources_DiscoveredViaAttribute_OnProperty_InvokedEachTimeAsync()
    {
        // Arrange
        var skill = new AttributedResourceDynamicPropertySkill();
        var resource = skill.Resources![0];

        // Act
        var first = await resource.ReadAsync();
        var second = await resource.ReadAsync();

        // Assert — property getter is called on each ReadAsync, producing different values
        Assert.Equal("call-1", first?.ToString());
        Assert.Equal("call-2", second?.ToString());
        Assert.Equal(2, skill.CallCount);
    }

    [Fact]
    public void Resources_DiscoveredViaAttribute_OnMethods_WithCorrectMetadata()
    {
        // Arrange
        var skill = new AttributedResourceMethodsSkill();

        // Act
        var resources = skill.Resources;

        // Assert
        Assert.NotNull(resources);
        Assert.Equal(4, resources!.Count);
        Assert.Contains(resources, r => r.Name == "dynamic");
        Assert.Contains(resources, r => r.Name == "GetData");
        Assert.Contains(resources, r => r.Name == "instance-dynamic");

        var describedResource = resources.First(r => r.Name == "info");
        Assert.Equal("Returns runtime info.", describedResource.Description);
    }

    [Fact]
    public async Task Resources_DiscoveredViaAttribute_OnMethods_CanBeReadAsync()
    {
        // Arrange
        var skill = new AttributedResourceMethodsSkill();

        // Act & Assert — static method
        var dynamicResource = skill.Resources!.First(r => r.Name == "dynamic");
        Assert.Equal("dynamic-value", (await dynamicResource.ReadAsync())?.ToString());

        // Act & Assert — instance method
        var instanceResource = skill.Resources!.First(r => r.Name == "instance-dynamic");
        Assert.Equal("instance-method-value", (await instanceResource.ReadAsync())?.ToString());
    }

    [Fact]
    public async Task AttributedFullSkill_IncludesContentWithSchema_AndCachesMembersAsync()
    {
        // Arrange
        var skill = new AttributedFullSkill();

        // Act & Assert — Content includes resources in body; scripts are in available_scripts
        Assert.Contains("<available_resources>", await skill.GetContentAsync());
        Assert.Contains("conversion-table", await skill.GetContentAsync());
        Assert.Contains("<available_scripts>", await skill.GetContentAsync());
        Assert.Contains("convert", await skill.GetContentAsync());

        // Act & Assert — discovered members are cached
        Assert.Same(skill.Resources, skill.Resources);
        Assert.Same(skill.Scripts, skill.Scripts);

        // Act & Assert — script has parameters schema
        var script = skill.Scripts![0];
        Assert.NotNull(script.ParametersSchema);
        Assert.Contains("value", script.ParametersSchema!.Value.GetRawText());
    }

    [Fact]
    public void NoAttributedMembers_NoOverrides_ReturnsNull()
    {
        // Arrange — skill with no attributes and no overrides; base discovery returns null (not empty list)
        var skill = new NoAttributesNoOverridesSkill();
        var baseType = typeof(AgentClassSkill<NoAttributesNoOverridesSkill>);
        var resourcesField = baseType.GetField("_resources", BindingFlags.Instance | BindingFlags.NonPublic);
        var scriptsField = baseType.GetField("_scripts", BindingFlags.Instance | BindingFlags.NonPublic);

        Assert.NotNull(resourcesField);
        Assert.NotNull(scriptsField);

        var resourcesLazy = (Lazy<IReadOnlyList<AgentSkillResource>?>)resourcesField!.GetValue(skill)!;
        var scriptsLazy = (Lazy<IReadOnlyList<AgentSkillScript>?>)scriptsField!.GetValue(skill)!;

        Assert.False(resourcesLazy.IsValueCreated);
        Assert.False(scriptsLazy.IsValueCreated);

        // Act & Assert
        Assert.Null(skill.Resources);
        Assert.Null(skill.Scripts);
        Assert.True(resourcesLazy.IsValueCreated);
        Assert.True(scriptsLazy.IsValueCreated);
        Assert.Null(resourcesLazy.Value);
        Assert.Null(scriptsLazy.Value);

        // Repeated access should not re-trigger discovery even when discovered value is null.
        Assert.Null(skill.Resources);
        Assert.Null(skill.Scripts);
        Assert.True(resourcesLazy.IsValueCreated);
        Assert.True(scriptsLazy.IsValueCreated);
        Assert.Null(resourcesLazy.Value);
        Assert.Null(scriptsLazy.Value);
    }

    [Fact]
    public void SubclassOverride_TakesPrecedence_OverAttributes()
    {
        // Arrange — skill has attributes AND overrides Resources/Scripts
        var skill = new AttributedWithOverrideSkill();

        // Act
        var resources = skill.Resources;
        var scripts = skill.Scripts;

        // Assert — overrides win, not reflected members
        Assert.NotNull(resources);
        Assert.Single(resources!);
        Assert.Equal("manual-resource", resources![0].Name);
        Assert.NotNull(scripts);
        Assert.Single(scripts!);
        Assert.Equal("ManualScript", scripts![0].Name);
    }

    [Fact]
    public async Task MixedStaticAndInstance_AllDiscoveredAndInvocableAsync()
    {
        // Arrange
        var skill = new MixedStaticInstanceSkill();

        // Act & Assert — correct counts
        Assert.NotNull(skill.Resources);
        Assert.Equal(2, skill.Resources!.Count);
        Assert.NotNull(skill.Scripts);
        Assert.Equal(2, skill.Scripts!.Count);

        // Act & Assert — all resources produce values
        foreach (var resource in skill.Resources!)
        {
            var value = await resource.ReadAsync();
            Assert.NotNull(value);
        }

        // Act & Assert — all scripts produce values
        foreach (var script in skill.Scripts!)
        {
            var result = await script.RunAsync(skill, null, null, CancellationToken.None);
            Assert.NotNull(result);
        }
    }

    [Fact]
    public async Task SerializerOptions_UsedForReflectedMembersAsync()
    {
        // Arrange
        var skill = new AttributedSkillWithCustomSerializer();
        var jso = SkillTestJsonContext.Default.Options;

        // Act & Assert — script with custom JSO
        var script = skill.Scripts!.First(s => s.Name == "lookup");
        var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
        using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
        var args = argsDoc.RootElement;
        var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None);
        Assert.NotNull(scriptResult);
        Assert.Contains("test", scriptResult!.ToString()!);
        Assert.Contains("3", scriptResult!.ToString()!);

        // Act & Assert — resource with custom JSO
        var resourceResult = await skill.Resources![0].ReadAsync();
        Assert.NotNull(resourceResult);
        Assert.Contains("light", resourceResult!.ToString()!);
    }

    [Fact]
    public async Task Content_RendersResources_InBodyAsync()
    {
        // Arrange
        var skill = new AttributedResourcePropertiesSkill();

        // Act
        var content = await skill.GetContentAsync();

        // Assert — resources are rendered in body content; described resources include description attribute
        Assert.Contains("<available_resources>", content);
        Assert.Contains("ref-data", content);
        Assert.Contains("description=\"Some important data.\"", content);
    }

    [Fact]
    public void IndexerPropertyWithResourceAttribute_ThrowsInvalidOperationException()
    {
        // Arrange
        var skill = new IndexerResourceSkill();

        // Act & Assert — accessing Resources triggers discovery which should throw
        var ex = Assert.Throws<InvalidOperationException>(() => skill.Resources);
        Assert.Contains("indexer", ex.Message, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("IndexerResourceSkill", ex.Message);
    }

    [Fact]
    public void ResourceMethodWithUnsupportedParameters_ThrowsInvalidOperationException()
    {
        // Arrange
        var skill = new UnsupportedParamResourceMethodSkill();

        // Act & Assert — accessing Resources triggers discovery which should throw
        var ex = Assert.Throws<InvalidOperationException>(() => skill.Resources);
        Assert.Contains("content", ex.Message);
        Assert.Contains("String", ex.Message);
    }

    [Fact]
    public async Task ResourceMethodWithServiceProviderParam_IsDiscoveredSuccessfullyAsync()
    {
        // Arrange
        var skill = new ServiceProviderResourceMethodSkill();
        var sp = new ServiceCollection().BuildServiceProvider();

        // Act
        var resources = skill.Resources;

        // Assert
        Assert.NotNull(resources);
        Assert.Single(resources!);
        Assert.Equal("sp-resource", resources![0].Name);

        var value = await resources[0].ReadAsync(sp);
        Assert.Equal("from-sp-method", value?.ToString());
    }

    [Fact]
    public async Task ResourceMethodWithCancellationTokenParam_IsDiscoveredSuccessfullyAsync()
    {
        // Arrange
        var skill = new CancellationTokenResourceMethodSkill();

        // Act
        var resources = skill.Resources;

        // Assert
        Assert.NotNull(resources);
        Assert.Single(resources!);
        Assert.Equal("ct-resource", resources![0].Name);

        var value = await resources[0].ReadAsync();
        Assert.Equal("from-ct-method", value?.ToString());
    }

    [Fact]
    public async Task ResourceMethodWithBothServiceProviderAndCancellationToken_IsDiscoveredSuccessfullyAsync()
    {
        // Arrange
        var skill = new BothParamsResourceMethodSkill();
        var sp = new ServiceCollection().BuildServiceProvider();

        // Act
        var resources = skill.Resources;

        // Assert
        Assert.NotNull(resources);
        Assert.Single(resources!);
        Assert.Equal("both-resource", resources![0].Name);

        var value = await resources[0].ReadAsync(sp);
        Assert.Equal("from-both-method", value?.ToString());
    }

    [Fact]
    public async Task CreateScript_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync()
    {
        // Arrange
        var skill = new CreateMethodsFallbackSkill();

        // Act — invoke script that uses custom types, relying on SerializerOptions fallback
        var script = skill.Scripts!.First(s => s.Name == "Lookup");
        var jso = SkillTestJsonContext.Default.Options;
        var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "fallback", MaxResults = 7 }, jso);
        using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
        var args = argsDoc.RootElement;
        var result = await script.RunAsync(skill, args, null, CancellationToken.None);

        // Assert
        Assert.NotNull(result);
        Assert.Contains("fallback", result!.ToString()!);
        Assert.Contains("7", result!.ToString()!);
    }

    [Fact]
    public async Task CreateResource_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync()
    {
        // Arrange
        var skill = new CreateMethodsFallbackSkill();

        // Act — read resource that uses custom types, relying on SerializerOptions fallback
        var resource = skill.Resources!.First(r => r.Name == "config");
        var result = await resource.ReadAsync();

        // Assert
        Assert.NotNull(result);
        Assert.Contains("dark", result!.ToString()!);
    }

    [Fact]
    public async Task CreateScript_UsesExplicitJso_OverSerializerOptionsAsync()
    {
        // Arrange
        var skill = new CreateMethodsExplicitJsoSkill();

        // Act — invoke script that passes explicit JSO (should take precedence over SerializerOptions)
        var script = skill.Scripts!.First(s => s.Name == "Lookup");
        var jso = SkillTestJsonContext.Default.Options;
        var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "explicit", MaxResults = 2 }, jso);
        using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
        var args = argsDoc.RootElement;
        var result = await script.RunAsync(skill, args, null, CancellationToken.None);

        // Assert
        Assert.NotNull(result);
        Assert.Contains("explicit", result!.ToString()!);
        Assert.Contains("2", result!.ToString()!);
    }

    [Fact]
    public async Task CreateResource_UsesExplicitJso_OverSerializerOptionsAsync()
    {
        // Arrange
        var skill = new CreateMethodsExplicitJsoSkill();

        // Act — read resource that passes explicit JSO (should take precedence over SerializerOptions)
        var resource = skill.Resources!.First(r => r.Name == "config");
        var result = await resource.ReadAsync();

        // Assert
        Assert.NotNull(result);
        Assert.Contains("explicit-theme", result!.ToString()!);
    }

    [Fact]
    public void DuplicateResourceNames_FromProperties_ThrowsInvalidOperationException()
    {
        // Arrange
        var skill = new DuplicateResourcePropertiesSkill();

        // Act & Assert
        var ex = Assert.Throws<InvalidOperationException>(() => _ = skill.Resources);
        Assert.Contains("data", ex.Message);
        Assert.Contains("already has a resource", ex.Message);
    }

    [Fact]
    public void DuplicateResourceNames_FromPropertyAndMethod_ThrowsInvalidOperationException()
    {
        // Arrange
        var skill = new DuplicateResourcePropertyAndMethodSkill();

        // Act & Assert
        var ex = Assert.Throws<InvalidOperationException>(() => _ = skill.Resources);
        Assert.Contains("data", ex.Message);
        Assert.Contains("already has a resource", ex.Message);
    }

    [Fact]
    public void DuplicateResourceNames_FromMethods_ThrowsInvalidOperationException()
    {
        // Arrange
        var skill = new DuplicateResourceMethodsSkill();

        // Act & Assert
        var ex = Assert.Throws<InvalidOperationException>(() => _ = skill.Resources);
        Assert.Contains("data", ex.Message);
        Assert.Contains("already has a resource", ex.Message);
    }

    [Fact]
    public void DuplicateScriptNames_ThrowsInvalidOperationException()
    {
        // Arrange
        var skill = new DuplicateScriptsSkill();

        // Act & Assert
        var ex = Assert.Throws<InvalidOperationException>(() => _ = skill.Scripts);
        Assert.Contains("do-work", ex.Message);
        Assert.Contains("already has a script", ex.Message);
    }

    #region Test skill classes

    private sealed class MinimalClassSkill : AgentClassSkill<MinimalClassSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill.");

        protected override string Instructions => "Minimal skill body.";
    }

    private sealed class FullClassSkill : AgentClassSkill<FullClassSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("full", "A full skill with resources and scripts.");

        protected override string Instructions => "Full skill body.";

        public override IReadOnlyList<AgentSkillResource>? Resources =>
        [
            this.CreateResource("test-resource", "resource content"),
        ];

        public override IReadOnlyList<AgentSkillScript>? Scripts =>
        [
            this.CreateScript("TestScript", TestScript),
        ];

        private static string TestScript(double value) =>
            JsonSerializer.Serialize(new { result = value * 2 });
    }

    private sealed class ResourceOnlySkill : AgentClassSkill<ResourceOnlySkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("resource-only", "Skill with resources only.");

        protected override string Instructions => "Body.";

        public override IReadOnlyList<AgentSkillResource>? Resources =>
        [
            this.CreateResource("data", "some data"),
        ];
    }

    private sealed class ScriptOnlySkill : AgentClassSkill<ScriptOnlySkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("script-only", "Skill with scripts only.");

        protected override string Instructions => "Body.";

        public override IReadOnlyList<AgentSkillScript>? Scripts =>
        [
            this.CreateScript("ToUpper", (string input) => input.ToUpperInvariant()),
        ];
    }

    private sealed class LazyLoadedSkill : AgentClassSkill<LazyLoadedSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("lazy-loaded", "Skill with lazily created resources and scripts.");

        protected override string Instructions => "Body.";

        public int ResourceCreationCount { get; private set; }

        public int ScriptCreationCount { get; private set; }

        private IReadOnlyList<AgentSkillResource>? _resources;
        private IReadOnlyList<AgentSkillScript>? _scripts;

        public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??= this.CreateResources();

        public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??= this.CreateScripts();

        private IReadOnlyList<AgentSkillResource> CreateResources()
        {
            this.ResourceCreationCount++;
            return [this.CreateResource("lazy-resource", "resource content")];
        }

        private IReadOnlyList<AgentSkillScript> CreateScripts()
        {
            this.ScriptCreationCount++;
            return [this.CreateScript("LazyScript", () => "done")];
        }
    }

    private sealed class CustomTypeSkill : AgentClassSkill<CustomTypeSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("custom-type-skill", "Skill with custom-typed scripts and resources.");

        protected override string Instructions => "Body.";

        public override IReadOnlyList<AgentSkillResource>? Resources =>
        [
            this.CreateResource("config", () => new SkillConfig
            {
                Theme = "dark",
                Verbose = true
            }, serializerOptions: SkillTestJsonContext.Default.Options),
        ];

        public override IReadOnlyList<AgentSkillScript>? Scripts =>
        [
            this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse
            {
                Items = [$"result for {request.Query}"],
                TotalCount = request.MaxResults,
            }, serializerOptions: SkillTestJsonContext.Default.Options),
        ];
    }

#pragma warning disable IDE0051 // Remove unused private members
    private sealed class AttributedScriptsSkill : AgentClassSkill<AttributedScriptsSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-scripts", "Skill with various attributed scripts.");

        protected override string Instructions => "Body.";

        [AgentSkillScript("do-work")]
        private static string DoWork(string input) => input.ToUpperInvariant();

        [AgentSkillScript]
        private static string DefaultNamed(string input) => input.ToUpperInvariant();

        [AgentSkillScript("process")]
        [Description("Processes the input.")]
        private static string Process(string input) => input;

        [AgentSkillScript("append")]
        private string Append(string input) => input + "-suffix";
    }

    private sealed class AttributedResourcePropertiesSkill : AgentClassSkill<AttributedResourcePropertiesSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-props", "Skill with various attributed resource properties.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("ref-data")]
        public string ReferenceData => "Reference content.";

        [AgentSkillResource]
        public string DefaultNamed => "Some data.";

        [AgentSkillResource("data")]
        [Description("Some important data.")]
        public string DescribedData => "content";

        [AgentSkillResource("static-data")]
        public static string StaticData => "Static content.";
    }

    private sealed class AttributedResourceMethodsSkill : AgentClassSkill<AttributedResourceMethodsSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-methods", "Skill with various attributed resource methods.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("dynamic")]
        private static string GetDynamic() => "dynamic-value";

        [AgentSkillResource]
        private static string GetData() => "data";

        [AgentSkillResource("info")]
        [Description("Returns runtime info.")]
        private static string GetInfo() => "runtime-info";

        [AgentSkillResource("instance-dynamic")]
        private string GetValue() => "instance-method-value";
    }

    private sealed class AttributedFullSkill : AgentClassSkill<AttributedFullSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-full", "Full skill with attributed resources and scripts.");

        protected override string Instructions => "Convert units using the table.";

        [AgentSkillResource("conversion-table")]
        public string ConversionTable => "miles -> km: 1.60934";

        [AgentSkillScript("convert")]
        private static string Convert(double value, double factor) =>
            JsonSerializer.Serialize(new { result = value * factor });
    }

    private sealed class NoAttributesNoOverridesSkill : AgentClassSkill<NoAttributesNoOverridesSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("no-attrs", "Skill with no attributes or overrides.");

        protected override string Instructions => "Body.";
    }

    private sealed class AttributedWithOverrideSkill : AgentClassSkill<AttributedWithOverrideSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-override", "Skill with attributes and overrides.");

        protected override string Instructions => "Body.";

        // These attributes should be ignored because Resources/Scripts are overridden.
        [AgentSkillResource("ignored-resource")]
        public string IgnoredData => "ignored";

        [AgentSkillScript("ignored-script")]
        private static string IgnoredScript() => "ignored";

        public override IReadOnlyList<AgentSkillResource>? Resources =>
        [
            this.CreateResource("manual-resource", "manual content"),
        ];

        public override IReadOnlyList<AgentSkillScript>? Scripts =>
        [
            this.CreateScript("ManualScript", () => "manual result"),
        ];
    }

    private sealed class AttributedResourceDynamicPropertySkill : AgentClassSkill<AttributedResourceDynamicPropertySkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-dynamic-prop", "Skill with dynamic property resource.");

        protected override string Instructions => "Body.";

        public int CallCount { get; private set; }

        [AgentSkillResource("counter")]
        public string Counter => $"call-{++this.CallCount}";
    }

    private sealed class AttributedSkillWithCustomSerializer : AgentClassSkill<AttributedSkillWithCustomSerializer>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-custom-jso", "Skill with custom serializer options.");

        protected override string Instructions => "Body.";

        protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options;

        [AgentSkillResource("config")]
        public SkillConfig Config => new() { Theme = "light", Verbose = false };

        [AgentSkillScript("lookup")]
        private static LookupResponse Lookup(LookupRequest request) => new()
        {
            Items = [$"result for {request.Query}"],
            TotalCount = request.MaxResults,
        };
    }

    private sealed class MixedStaticInstanceSkill : AgentClassSkill<MixedStaticInstanceSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("mixed-static-instance", "Skill with both static and instance members.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("static-resource")]
        public static string StaticResource => "static-value";

        [AgentSkillResource("instance-resource")]
        public string InstanceResource => "instance-data";

        [AgentSkillScript("static-script")]
        private static string StaticScript() => "static-result";

        [AgentSkillScript("instance-script")]
        private string InstanceScript() => "instance-data";
    }

    private sealed class CreateMethodsFallbackSkill : AgentClassSkill<CreateMethodsFallbackSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("create-fallback", "Skill testing SerializerOptions fallback for CreateScript/CreateResource.");

        protected override string Instructions => "Body.";

        protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options;

        public override IReadOnlyList<AgentSkillResource>? Resources =>
        [
            this.CreateResource("config", () => new SkillConfig
            {
                Theme = "dark",
                Verbose = true,
            }),
        ];

        public override IReadOnlyList<AgentSkillScript>? Scripts =>
        [
            this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse
            {
                Items = [$"result for {request.Query}"],
                TotalCount = request.MaxResults,
            }),
        ];
    }

    private sealed class CreateMethodsExplicitJsoSkill : AgentClassSkill<CreateMethodsExplicitJsoSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("create-explicit-jso", "Skill testing explicit JSO overrides SerializerOptions.");

        protected override string Instructions => "Body.";

        // SerializerOptions is intentionally null — explicit JSO passed to CreateScript/CreateResource should be used.
        public override IReadOnlyList<AgentSkillResource>? Resources =>
        [
            this.CreateResource("config", () => new SkillConfig
            {
                Theme = "explicit-theme",
                Verbose = false,
            }, serializerOptions: SkillTestJsonContext.Default.Options),
        ];

        public override IReadOnlyList<AgentSkillScript>? Scripts =>
        [
            this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse
            {
                Items = [$"result for {request.Query}"],
                TotalCount = request.MaxResults,
            }, serializerOptions: SkillTestJsonContext.Default.Options),
        ];
    }

    private sealed class IndexerResourceSkill : AgentClassSkill<IndexerResourceSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("indexer-skill", "Skill with indexer resource.");

        protected override string Instructions => "Body.";

        private readonly Dictionary<string, string> _data = new() { ["key"] = "value" };

        [AgentSkillResource("indexed")]
        public string this[string key] => this._data[key];
    }

    private sealed class UnsupportedParamResourceMethodSkill : AgentClassSkill<UnsupportedParamResourceMethodSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("unsupported-param-skill", "Skill with unsupported param resource method.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("bad-resource")]
        private static string GetData(string content) => content;
    }

    private sealed class ServiceProviderResourceMethodSkill : AgentClassSkill<ServiceProviderResourceMethodSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("sp-param-skill", "Skill with IServiceProvider param resource method.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("sp-resource")]
        private static string GetData(IServiceProvider? sp) => "from-sp-method";
    }

    private sealed class CancellationTokenResourceMethodSkill : AgentClassSkill<CancellationTokenResourceMethodSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("ct-param-skill", "Skill with CancellationToken param resource method.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("ct-resource")]
        private static string GetData(CancellationToken ct) => "from-ct-method";
    }

    private sealed class BothParamsResourceMethodSkill : AgentClassSkill<BothParamsResourceMethodSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("both-param-skill", "Skill with both IServiceProvider and CancellationToken param resource method.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("both-resource")]
        private static string GetData(IServiceProvider? sp, CancellationToken ct) => "from-both-method";
    }
    private sealed class DuplicateResourcePropertiesSkill : AgentClassSkill<DuplicateResourcePropertiesSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-props", "Skill with duplicate resource property names.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("data")]
        public string Data1 => "value1";

        [AgentSkillResource("data")]
        public string Data2 => "value2";
    }

    private sealed class DuplicateResourcePropertyAndMethodSkill : AgentClassSkill<DuplicateResourcePropertyAndMethodSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-prop-method", "Skill with duplicate resource from property and method.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("data")]
        public string Data => "property-value";

        [AgentSkillResource("data")]
        private static string GetData() => "method-value";
    }

    private sealed class DuplicateResourceMethodsSkill : AgentClassSkill<DuplicateResourceMethodsSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-methods", "Skill with duplicate resource method names.");

        protected override string Instructions => "Body.";

        [AgentSkillResource("data")]
        private static string GetData1() => "value1";

        [AgentSkillResource("data")]
        private static string GetData2() => "value2";
    }

    private sealed class DuplicateScriptsSkill : AgentClassSkill<DuplicateScriptsSkill>
    {
        public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-scripts", "Skill with duplicate script names.");

        protected override string Instructions => "Body.";

        [AgentSkillScript("do-work")]
        private static string DoWork1(string input) => input.ToUpperInvariant();

        [AgentSkillScript("do-work")]
        private static string DoWork2(string input) => input + "-suffix";
    }
#pragma warning restore IDE0051 // Remove unused private members

    #endregion
}
