using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using Sbroenne.ExcelMcp.Generators.Common;

namespace Sbroenne.ExcelMcp.Generators;

/// <summary>
/// Generates ServiceRegistry constants and DTOs from Core command interfaces
/// marked with [ServiceCategory] attribute.
/// </summary>
[Generator]
public class ServiceRegistryGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Find all interfaces with [ServiceCategory] attribute
        var interfaceDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (s, _) => SyntaxHelper.IsInterfaceWithAttributes(s),
                transform: static (ctx, _) => SyntaxHelper.GetServiceInterfaceOrNull(ctx))
            .Where(static m => m is not null);

        // Combine with compilation
        var compilationAndInterfaces = context.CompilationProvider.Combine(interfaceDeclarations.Collect());

        // Generate source
        context.RegisterSourceOutput(compilationAndInterfaces,
            static (spc, source) => Execute(source.Left, source.Right!, spc));
    }

    private static void Execute(Compilation compilation, ImmutableArray<InterfaceDeclarationSyntax?> interfaces, SourceProductionContext context)
    {
        if (interfaces.IsDefaultOrEmpty)
            return;

        var distinctInterfaces = interfaces
            .Where(i => i is not null)
            .Cast<InterfaceDeclarationSyntax>()
            .Distinct()
            .ToList();

        if (distinctInterfaces.Count == 0)
            return;

        var allCategories = new List<ServiceInfo>();

        foreach (var interfaceDecl in distinctInterfaces)
        {
            var model = compilation.GetSemanticModel(interfaceDecl.SyntaxTree);
            var interfaceSymbol = model.GetDeclaredSymbol(interfaceDecl) as INamedTypeSymbol;

            if (interfaceSymbol is null)
                continue;

            // Use shared extractor
            var info = ServiceInfoExtractor.ExtractServiceInfo(interfaceSymbol);
            if (info is null)
                continue;

            allCategories.Add(info);

            // Get fully-qualified interface name for dispatch generation
            var interfaceFullName = interfaceSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
            if (interfaceFullName.StartsWith("global::"))
                interfaceFullName = interfaceFullName.Substring(8);

            // Generate ServiceRegistry partial for this category
            var registryCode = GenerateServiceRegistry(info);
            context.AddSource($"ServiceRegistry.{info.CategoryPascal}.g.cs", SourceText.From(registryCode, Encoding.UTF8));

            // Generate service dispatch for this category
            var dispatchCode = GenerateServiceDispatch(info, interfaceFullName);
            context.AddSource($"ServiceRegistry.{info.CategoryPascal}.Dispatch.g.cs", SourceText.From(dispatchCode, Encoding.UTF8));

            // Generate comparison file for validation (existing vs generated)
            var comparisonCode = GenerateComparisonFile(info);
            context.AddSource($"Comparison.{info.CategoryPascal}.g.cs", SourceText.From(comparisonCode, Encoding.UTF8));
        }

        // Emit a manifest file for the CLI generator to consume
        if (allCategories.Count > 0)
        {
            var manifestCode = GenerateCliManifest(allCategories);
            context.AddSource("_CliCategories.g.cs", SourceText.From(manifestCode, Encoding.UTF8));

            // Emit a JSON manifest for skill documentation generation
            var skillManifestCode = GenerateSkillManifest(allCategories);
            context.AddSource("_SkillManifest.g.cs", SourceText.From(skillManifestCode, Encoding.UTF8));

            // Emit shared dispatch helper methods (DeserializeArgs, ParseEnumValue, etc.)
            var helpersCode = GenerateDispatchHelpers();
            context.AddSource("ServiceRegistry.DispatchHelpers.g.cs", SourceText.From(helpersCode, Encoding.UTF8));
        }
    }

    // =====================================================
    // Code Generation Methods (local to this generator)
    // =====================================================

    private static string GenerateServiceRegistry(ServiceInfo info)
    {
        var sb = new StringBuilder();

        sb.AppendLine("// <auto-generated />");;
        sb.AppendLine("#nullable enable");
        sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member");
        sb.AppendLine();
        sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
        sb.AppendLine();

        // Generate the Action enum with kebab-case JSON names
        sb.AppendLine("/// <summary>");
        sb.AppendLine($"/// Generated action enum for {info.Category} operations.");
        sb.AppendLine("/// </summary>");
        sb.AppendLine("[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter<" + info.CategoryPascal + "Action>))]");
        sb.AppendLine($"public enum {info.CategoryPascal}Action");
        sb.AppendLine("{");
        for (int i = 0; i < info.Methods.Count; i++)
        {
            var method = info.Methods[i];
            var comma = i < info.Methods.Count - 1 ? "," : "";
            sb.AppendLine($"    [System.Text.Json.Serialization.JsonStringEnumMemberName(\"{method.ActionName}\")]");
            sb.AppendLine($"    {method.MethodName}{comma}");
        }
        sb.AppendLine("}");
        sb.AppendLine();

        sb.AppendLine("/// <summary>");
        sb.AppendLine($"/// Generated service registry for {info.Category} operations.");
        sb.AppendLine("/// </summary>");
        sb.AppendLine($"public static partial class ServiceRegistry");
        sb.AppendLine("{");
        sb.AppendLine($"    public static partial class {info.CategoryPascal}");
        sb.AppendLine("    {");
        sb.AppendLine($"        public const string Category = \"{info.Category}\";");
        sb.AppendLine($"        public const string McpToolName = \"{info.McpToolName}\";");
        sb.AppendLine($"        public const bool RequiresSession = {(info.NoSession ? "false" : "true")};");

        // CLI command name: remove underscores from tool name
        var cliCommandName = info.McpToolName.Replace("_", "");
        sb.AppendLine($"        public const string CliCommandName = \"{cliCommandName}\";");
        sb.AppendLine();

        // Generate action constants
        sb.AppendLine("        // Action constants");
        foreach (var method in info.Methods)
        {
            sb.AppendLine($"        public const string {method.MethodName}Action = \"{method.ActionName}\";");
        }
        sb.AppendLine();

        // Generate full command strings
        sb.AppendLine("        // Full command strings (category.action)");
        foreach (var method in info.Methods)
        {
            sb.AppendLine($"        public const string {method.MethodName}Command = \"{info.Category}.{method.ActionName}\";");
        }
        sb.AppendLine();

        // Generate ValidActions array for CLI help/validation
        sb.AppendLine("        /// <summary>");
        sb.AppendLine("        /// All valid action strings for this category.");
        sb.AppendLine("        /// </summary>");
        var actionList = string.Join(", ", info.Methods.Select(m => $"\"{m.ActionName}\""));
        sb.AppendLine($"        public static readonly string[] ValidActions = [{actionList}];");
        sb.AppendLine();

        // Generate Description constant for CLI help
        var escapedDescription = (info.XmlDocSummary ?? $"{info.CategoryPascal} operations")
            .Replace("\\", "\\\\")
            .Replace("\"", "\\\"")
            .Replace("\r", "")
            .Replace("\n", " ")
            .Trim();
        sb.AppendLine("        /// <summary>");
        sb.AppendLine("        /// Human-readable description from interface XML documentation.");
        sb.AppendLine("        /// </summary>");
        sb.AppendLine($"        public const string Description = \"{escapedDescription}\";");
        sb.AppendLine();

        // Generate TryParseAction method for this category
        sb.AppendLine("        /// <summary>");
        sb.AppendLine("        /// Parses a kebab-case action string to the strongly-typed enum.");
        sb.AppendLine("        /// </summary>");
        sb.AppendLine($"        public static bool TryParseAction(string actionString, out {info.CategoryPascal}Action action)");
        sb.AppendLine("        {");
        sb.AppendLine("            action = default;");
        sb.AppendLine("            return actionString switch");
        sb.AppendLine("            {");
        foreach (var method in info.Methods)
        {
            sb.AppendLine($"                \"{method.ActionName}\" => SetAndReturn(out action, {info.CategoryPascal}Action.{method.MethodName}),");
        }
        sb.AppendLine("                _ => false");
        sb.AppendLine("            };");
        sb.AppendLine("        }");
        sb.AppendLine();
        sb.AppendLine("        private static bool SetAndReturn<T>(out T action, T value)");
        sb.AppendLine("        {");
        sb.AppendLine("            action = value;");
        sb.AppendLine("            return true;");
        sb.AppendLine("        }");
        sb.AppendLine();

        // Generate ToActionString method for this category
        sb.AppendLine("        /// <summary>");
        sb.AppendLine("        /// Converts the action enum to its kebab-case string representation.");
        sb.AppendLine("        /// </summary>");
        sb.AppendLine($"        public static string ToActionString({info.CategoryPascal}Action action)");
        sb.AppendLine("        {");
        sb.AppendLine("            return action switch");
        sb.AppendLine("            {");
        foreach (var method in info.Methods)
        {
            sb.AppendLine($"                {info.CategoryPascal}Action.{method.MethodName} => \"{method.ActionName}\",");
        }
        sb.AppendLine($"                _ => throw new System.ArgumentException($\"Unknown {info.CategoryPascal}Action: {{action}}\")");
        sb.AppendLine("            };");
        sb.AppendLine("        }");
        sb.AppendLine();

        // Generate request DTOs for methods with parameters
        foreach (var method in info.Methods.Where(m => m.Parameters.Count > 0))
        {
            sb.AppendLine($"        /// <summary>Request DTO for {method.ActionName} action</summary>");
            sb.AppendLine($"        public sealed record {method.MethodName}Request(");

            var paramLines = method.Parameters.Select((p, i) =>
            {
                var suffix = i < method.Parameters.Count - 1 ? "," : "";
                if (p.HasDefault)
                {
                    return $"            {p.TypeName} {StringHelper.ToPascalCase(p.Name)} = {p.DefaultValue}{suffix}";
                }
                return $"            {p.TypeName} {StringHelper.ToPascalCase(p.Name)}{suffix}";
            });

            sb.AppendLine(string.Join("\r\n", paramLines));
            sb.AppendLine("        );");
            sb.AppendLine();
        }

        // Generate service args classes for JSON deserialization
        sb.AppendLine("        // ============================================");
        sb.AppendLine("        // Service Args Classes (generated)");
        sb.AppendLine("        // ============================================");
        sb.AppendLine();

        foreach (var method in info.Methods)
        {
            GenerateArgsClass(sb, method);
        }

        // Generate MCP forward methods
        sb.AppendLine("        // ============================================");
        sb.AppendLine("        // MCP Forward Methods (generated)");
        sb.AppendLine("        // ============================================");
        sb.AppendLine();

        foreach (var method in info.Methods)
        {
            GenerateForwardMethod(sb, info, method);
        }

        // Generate the RouteAction switch
        sb.AppendLine("        /// <summary>");
        sb.AppendLine("        /// Routes an action to the appropriate forward method.");
        sb.AppendLine("        /// </summary>");
        sb.AppendLine($"        public static string RouteAction(");
        sb.AppendLine($"            {info.CategoryPascal}Action action,");
        sb.AppendLine($"            string sessionId,");
        sb.AppendLine($"            System.Func<string, string, object?, string> forwardToService,");

        // Collect all unique exposed parameters across all methods
        var allExposedParams = GetAllExposedParameters(info);
        for (int i = 0; i < allExposedParams.Count; i++)
        {
            var p = allExposedParams[i];
            var comma = i < allExposedParams.Count - 1 ? "," : ")";
            sb.AppendLine($"            {p.TypeName} {p.Name} = {p.DefaultValue ?? "null"}{comma}");
        }

        sb.AppendLine("        {");
        sb.AppendLine("            return action switch");
        sb.AppendLine("            {");
        foreach (var method in info.Methods)
        {
            var forwardArgs = BuildForwardArgs(method, allExposedParams);
            sb.AppendLine($"                {info.CategoryPascal}Action.{method.MethodName} => Forward{method.MethodName}(sessionId, forwardToService{forwardArgs}),");
        }
        sb.AppendLine($"                _ => throw new System.ArgumentException($\"Unknown action: {{action}}\")");
        sb.AppendLine("            };");
        sb.AppendLine("        }");
        sb.AppendLine();

        // Generate CLI RouteCliArgs method
        GenerateCliRouteMethod(sb, info);

        sb.AppendLine("    }");
        sb.AppendLine("}");

        return sb.ToString();
    }

    private static void GenerateCliRouteMethod(StringBuilder sb, ServiceInfo info)
    {
        sb.AppendLine("        // ============================================");
        sb.AppendLine("        // CLI Routing (generated)");
        sb.AppendLine("        // ============================================");
        sb.AppendLine();
        sb.AppendLine("        /// <summary>");
        sb.AppendLine("        /// Builds the args object for a CLI action.");
        sb.AppendLine("        /// Returns (command, args) tuple for sending to service.");
        sb.AppendLine("        /// </summary>");
        sb.AppendLine($"        public static (string Command, object? Args) RouteCliArgs(");
        sb.AppendLine($"            string action,");

        // Collect all unique exposed parameters
        var allExposedParams = GetAllExposedParameters(info);
        for (int i = 0; i < allExposedParams.Count; i++)
        {
            var p = allExposedParams[i];
            var comma = i < allExposedParams.Count - 1 ? "," : ")";
            sb.AppendLine($"            {p.TypeName} {p.Name} = {p.DefaultValue ?? "null"}{comma}");
        }

        sb.AppendLine("        {");
        sb.AppendLine($"            var command = $\"{info.Category}.{{action}}\";");
        sb.AppendLine();

        // Generate switch statement with per-action validation
        sb.AppendLine("            switch (action)");
        sb.AppendLine("            {");

        foreach (var method in info.Methods)
        {
            bool hasFileOrValue = method.Parameters.Any(p => p.IsFileOrValue);

            sb.AppendLine($"                case \"{method.ActionName}\":");
            // Use block scope when FileOrValue resolution generates local variables
            // to avoid duplicate variable names across switch cases
            if (hasFileOrValue)
                sb.AppendLine("                {");

            // Emit RequireNotEmpty for required string parameters (skip FileOrValue - they validate after resolution)
            foreach (var p in method.Parameters.Where(p => !p.IsFileOrValue && (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"))) && StringHelper.IsStringType(p.TypeName)))
            {
                var paramName = p.IsFromString && p.IsEnum ? (p.ExposedName ?? p.Name) : p.Name;
                sb.AppendLine($"                    Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty({paramName}, \"{paramName}\", \"{method.ActionName}\");");
            }

            // Resolve FileOrValue parameters (read file content if file path provided)
            foreach (var p in method.Parameters.Where(p => p.IsFileOrValue))
            {
                sb.AppendLine($"                    var resolved{StringHelper.ToPascalCase(p.Name)} = Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.ResolveFileOrValue({p.Name}, {p.Name}{p.FileSuffix});");
                // Validate required FileOrValue parameters AFTER resolution
                if (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?")))
                {
                    sb.AppendLine($"                    Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty(resolved{StringHelper.ToPascalCase(p.Name)}, \"{p.Name}\", \"{method.ActionName}\");");
                }
            }

            var argsExpr = BuildCliArgsExpression(method);
            sb.AppendLine($"                    return (command, {argsExpr});");

            if (hasFileOrValue)
                sb.AppendLine("                }");
        }

        sb.AppendLine($"                default:");
        sb.AppendLine($"                    throw new System.ArgumentException($\"Unknown action: {{action}}\");");
        sb.AppendLine("            }");
        sb.AppendLine("        }");
        sb.AppendLine();

        // Generate RouteFromSettings (bridges CliSettings → RouteCliArgs)
        GenerateRouteFromSettings(sb, info, allExposedParams);

        // Generate CLI Settings class
        GenerateCliSettings(sb, info, allExposedParams);
    }

    private static void GenerateRouteFromSettings(StringBuilder sb, ServiceInfo info, List<ExposedParameter> allExposedParams)
    {
        sb.AppendLine("        /// <summary>");
        sb.AppendLine("        /// Routes a CLI action using CliSettings (bridges Settings → RouteCliArgs).");
        sb.AppendLine("        /// Used by generated CLI command classes.");
        sb.AppendLine("        /// </summary>");
        sb.AppendLine("        public static (string Command, object? Args) RouteFromSettings(string action, CliSettings settings)");
        sb.AppendLine("        {");
        sb.AppendLine("            return RouteCliArgs(");
        sb.AppendLine("                action,");

        for (int i = 0; i < allExposedParams.Count; i++)
        {
            var p = allExposedParams[i];
            var comma = i < allExposedParams.Count - 1 ? "," : ");";

            if (IsNestedCollectionType(p.TypeName))
            {
                // CLI Settings stores nested collections as string (JSON).
                // Deserialize back to the original type for RouteCliArgs.
                // Uses DeserializeNestedCollection which auto-wraps 1D arrays to 2D
                // (e.g., ["a","b"] → [["a","b"]]) for better LLM compatibility.
                var nonNullableType = p.TypeName.TrimEnd('?');
                sb.AppendLine($"                {p.Name}: !string.IsNullOrWhiteSpace(settings.{StringHelper.ToPascalCase(p.Name)}) ? ServiceRegistry.DeserializeNestedCollection<{nonNullableType}>(settings.{StringHelper.ToPascalCase(p.Name)}) : null{comma}");
            }
            else if (IsSimpleListType(p.TypeName))
            {
                // CLI Settings stores simple lists as string (JSON) for LLM compatibility.
                // LLMs pass JSON arrays (e.g., '["a","b"]') instead of repeated CLI flags.
                // Deserialize back to the original type for RouteCliArgs.
                var nonNullableType = p.TypeName.TrimEnd('?');
                sb.AppendLine($"                {p.Name}: !string.IsNullOrWhiteSpace(settings.{StringHelper.ToPascalCase(p.Name)}) ? ServiceRegistry.DeserializeList<{nonNullableType}>(settings.{StringHelper.ToPascalCase(p.Name)}) : null{comma}");
            }
            else if (IsTimeSpanType(p.TypeName))
            {
                sb.AppendLine($"                {p.Name}: Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.ParseTimeSpanOrSeconds(settings.{StringHelper.ToPascalCase(p.Name)}, \"{p.Name}\"){comma}");
            }
            else
            {
                sb.AppendLine($"                {p.Name}: settings.{StringHelper.ToPascalCase(p.Name)}{comma}");
            }
        }

        sb.AppendLine("        }");
        sb.AppendLine();
    }

    /// <summary>
    /// Detects nested collection types (e.g., List&lt;List&lt;object&gt;&gt;) that cannot be
    /// parsed by Spectre.Console from CLI arguments. These are emitted as string? in CLI Settings
    /// and deserialized from JSON in RouteFromSettings.
    /// </summary>
    private static bool IsNestedCollectionType(string typeName)
    {
        // Check for List<List<...>> pattern (possibly with global:: prefix and namespace qualifiers)
        var idx = typeName.IndexOf("List<", StringComparison.Ordinal);
        if (idx < 0) return false;
        var afterFirst = typeName.IndexOf("List<", idx + 5, StringComparison.Ordinal);
        return afterFirst >= 0;
    }

    /// <summary>
    /// Detects simple list types (e.g., List&lt;string&gt;) that are NOT nested collections.
    /// LLMs often pass JSON arrays (e.g., '["a","b"]') instead of repeated CLI flags
    /// (--flag a --flag b), so these are emitted as string? in CLI Settings and deserialized
    /// from JSON in RouteFromSettings.
    /// </summary>
    private static bool IsSimpleListType(string typeName)
    {
        return !IsNestedCollectionType(typeName) &&
               typeName.IndexOf("List<", StringComparison.Ordinal) >= 0;
    }

    private static bool IsTimeSpanType(string typeName)
    {
        return typeName.IndexOf("TimeSpan", StringComparison.Ordinal) >= 0;
    }

    /// <summary>
    /// Returns a short alias for backward compatibility with pre-generator CLI parameter names.
    /// Before code generation, CLI used short names like --sheet and --range.
    /// Now generator creates kebab-case from Core camelCase (e.g., sheetName -> --sheet-name).
    /// This method provides short aliases to maintain backward compatibility.
    /// </summary>
    private static string? GetShortAlias(string parameterName)
    {
        return parameterName switch
        {
            "sheetName" => "sheet",
            "rangeAddress" => "range",
            _ => null
        };
    }

    private static void GenerateCliSettings(StringBuilder sb, ServiceInfo info, List<ExposedParameter> allParams)
    {
        // Note: These types require Spectre.Console reference in consuming project
        sb.AppendLine("        /// <summary>");
        sb.AppendLine($"        /// Generated CLI settings for {info.CategoryPascal} commands.");
        sb.AppendLine("        /// Requires Spectre.Console package reference.");
        sb.AppendLine("        /// </summary>");
        sb.AppendLine("        public sealed class CliSettings : Spectre.Console.Cli.CommandSettings");
        sb.AppendLine("        {");

        // Action argument (always first)
        sb.AppendLine("            [Spectre.Console.Cli.CommandArgument(0, \"<ACTION>\")]");
        sb.AppendLine("            [System.ComponentModel.Description(\"The action to perform\")]");
        sb.AppendLine("            public string Action { get; init; } = string.Empty;");
        sb.AppendLine();

        // Session ID (always required for session-based tools)
        if (!info.NoSession)
        {
            sb.AppendLine("            [Spectre.Console.Cli.CommandOption(\"-s|--session <SESSION>\")]");
            sb.AppendLine("            [System.ComponentModel.Description(\"Session ID from 'session open' command\")]");
            sb.AppendLine("            public string SessionId { get; init; } = string.Empty;");
            sb.AppendLine();
        }

        // Generate all exposed parameters
        foreach (var p in allParams)
        {
            var optionName = StringHelper.ToKebabCase(p.Name);
            var description = p.DescriptionWithRequired ?? StringHelper.GetParameterDescription(p.Name);
            var escapedDescription = description
                .Replace("[", "[[").Replace("]", "]]") // Escape Spectre.Console markup characters
                .Replace("\"", "\\\"").Replace("\n", " ");
            var valuePlaceholder = p.Name.ToUpperInvariant();

            // Collection types (List<T>, List<List<T>>) are emitted as string? so CLI accepts
            // raw JSON. LLMs pass JSON arrays instead of repeated CLI flags.
            // Deserialized back to the original type in RouteFromSettings.
            var isCollectionForJson = IsNestedCollectionType(p.TypeName) || IsSimpleListType(p.TypeName);
            var cliTypeName = isCollectionForJson || IsTimeSpanType(p.TypeName) ? "string?" : p.TypeName;
            if (isCollectionForJson && !escapedDescription.Contains("JSON"))
                escapedDescription += " (JSON format)";
            if (IsTimeSpanType(p.TypeName) && escapedDescription.IndexOf("seconds", StringComparison.OrdinalIgnoreCase) < 0)
                escapedDescription += " Accepts seconds (e.g. 600) or TimeSpan format (e.g. 00:10:00).";

            // Add short aliases for backward compatibility with pre-generator CLI parameter names
            var shortAlias = GetShortAlias(p.Name);
            var optionSpec = shortAlias != null
                ? $"--{shortAlias}|--{optionName} <{valuePlaceholder}>"
                : $"--{optionName} <{valuePlaceholder}>";

            sb.AppendLine($"            [Spectre.Console.Cli.CommandOption(\"{optionSpec}\")]");
            sb.AppendLine($"            [System.ComponentModel.Description(\"{escapedDescription}\")]");
            sb.AppendLine($"            public {cliTypeName} {StringHelper.ToPascalCase(p.Name)} {{ get; init; }}");
            sb.AppendLine();
        }

        // Output path option (available on all commands)
        sb.AppendLine("            [Spectre.Console.Cli.CommandOption(\"-o|--output <PATH>\")]");
        sb.AppendLine("            [System.ComponentModel.Description(\"Write output to file instead of stdout. For image results, decodes and saves as binary file.\")]");
        sb.AppendLine("            public string? OutputPath { get; init; }");
        sb.AppendLine();

        sb.AppendLine("        }");
    }

    private static string BuildCliArgsExpression(MethodInfo method)
    {
        if (method.Parameters.Count == 0)
            return "null";

        var props = new List<string>();
        foreach (var p in method.Parameters)
        {
            string valueName;
            if (p.IsFileOrValue)
            {
                // Use resolved variable (ResolveFileOrValue applied in RouteCliArgs)
                valueName = $"resolved{StringHelper.ToPascalCase(p.Name)}";
            }
            else if (p.IsFromString && p.ExposedName != null)
            {
                // CLI passes string directly to service using exposed name
                valueName = p.ExposedName;
            }
            else
            {
                valueName = p.Name;
            }

            // Use ExposedName for JSON property names when FromString specifies alternate name
            var propName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
            var jsonName = char.ToLowerInvariant(propName[0]) + propName.Substring(1);
            props.Add($"{jsonName} = {valueName}");
        }

        return $"new {{ {string.Join(", ", props)} }}";
    }

    private static void GenerateForwardMethod(StringBuilder sb, ServiceInfo info, MethodInfo method)
    {
        sb.AppendLine($"        /// <summary>Forward method for {method.ActionName} action</summary>");

        // Build parameter list - Core params might need transforms
        var methodParams = new List<string> { "string sessionId", "System.Func<string, string, object?, string> forwardToService" };

        foreach (var p in method.Parameters)
        {
            if (p.IsFileOrValue)
            {
                // Expose both value and file params
                methodParams.Add($"string? {p.Name} = null");
                methodParams.Add($"string? {p.Name}{p.FileSuffix} = null");
            }
            else if (p.IsFromString && p.IsEnum)
            {
                // Expose as string
                var exposedName = p.ExposedName ?? p.Name;
                methodParams.Add($"string? {exposedName} = null");
            }
            else
            {
                // Keep as-is
                var defaultStr = p.HasDefault ? $" = {p.DefaultValue}" : "";
                // Make nullable if not already
                var typeName = p.TypeName.EndsWith("?") ? p.TypeName : $"{p.TypeName}?";
                methodParams.Add($"{typeName} {p.Name}{defaultStr}");
            }
        }

        sb.AppendLine($"        public static string Forward{method.MethodName}({string.Join(", ", methodParams)})");
        sb.AppendLine("        {");

        // Generate validation for required string parameters (skip FileOrValue - they validate after resolution)
        // RequireNotEmpty only works with string? parameters
        foreach (var p in method.Parameters.Where(p => !p.IsFileOrValue && (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"))) && StringHelper.IsStringType(p.TypeName)))
        {
            var paramName = p.IsFromString && p.IsEnum ? (p.ExposedName ?? p.Name) : p.Name;
            sb.AppendLine($"            Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty({paramName}, \"{paramName}\", \"{method.ActionName}\");");
        }

        // Generate transforms (only for FileOrValue parameters)
        // Note: FromString enum parameters are passed as-is to the service (service does parsing)
        foreach (var p in method.Parameters)
        {
            if (p.IsFileOrValue)
            {
                sb.AppendLine($"            var resolved{StringHelper.ToPascalCase(p.Name)} = Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.ResolveFileOrValue({p.Name}, {p.Name}{p.FileSuffix});");
                // Validate required FileOrValue parameters AFTER resolution
                if (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?")))
                {
                    sb.AppendLine($"            Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty(resolved{StringHelper.ToPascalCase(p.Name)}, \"{p.Name}\", \"{method.ActionName}\");");
                }
            }
            // FromString enum parameters: pass raw string to service (no pre-parsing)
        }

        // Build the request object - always use method-specific command constant
        if (method.Parameters.Count == 0)
        {
            sb.AppendLine($"            return forwardToService({method.MethodName}Command, sessionId, null);");
        }
        else
        {
            sb.AppendLine($"            return forwardToService({method.MethodName}Command, sessionId, new");
            sb.AppendLine("            {");
            foreach (var p in method.Parameters)
            {
                string valueExpr;
                if (p.IsFileOrValue)
                {
                    valueExpr = $"resolved{StringHelper.ToPascalCase(p.Name)}";
                }
                else if (p.IsFromString && p.ExposedName != null)
                {
                    // FromString parameters: pass the exposed name (raw string) to service
                    valueExpr = p.ExposedName;
                }
                else
                {
                    valueExpr = p.Name;
                }
                // Use ExposedName for property name when FromString attribute specifies an alternate name
                var propertyName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
                sb.AppendLine($"                {StringHelper.ToPascalCase(propertyName)} = {valueExpr},");
            }
            sb.AppendLine("            });");
        }

        sb.AppendLine("        }");
        sb.AppendLine();
    }

    private static void GenerateArgsClass(StringBuilder sb, MethodInfo method)
    {
        if (method.Parameters.Count == 0)
            return;

        sb.AppendLine($"        /// <summary>Generated args class for {method.ActionName} deserialization</summary>");
        sb.AppendLine($"        public sealed class {method.MethodName}Args");
        sb.AppendLine("        {");
        foreach (var p in method.Parameters)
        {
            // Property name must match what Forward methods and BuildCliArgsExpression produce
            var propertyName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
            var pascalName = StringHelper.ToPascalCase(propertyName);
            var argsType = MakeArgsPropertyType(p);
            sb.AppendLine($"            public {argsType} {pascalName} {{ get; set; }}");
        }
        sb.AppendLine("        }");
        sb.AppendLine();
    }

    /// <summary>
    /// Determines the property type for an args class.
    /// IsFromString/IsFileOrValue/IsEnum params are always string? (service does parsing).
    /// All other types are made nullable.
    /// </summary>
    private static string MakeArgsPropertyType(ParameterInfo p)
    {
        // Enums are kept as string? so service handlers can use existing Parse* methods
        if (p.IsFromString || p.IsFileOrValue || p.IsEnum)
            return "string?";

        var typeName = p.TypeName;
        if (typeName.EndsWith("?"))
            return typeName;

        return typeName + "?";
    }

    private static List<ExposedParameter> GetAllExposedParameters(ServiceInfo info)
    {
        var result = new Dictionary<string, ExposedParameter>(StringComparer.OrdinalIgnoreCase);

        foreach (var method in info.Methods)
        {
            foreach (var p in method.Parameters)
            {
                var isRequired = p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"));

                if (p.IsFileOrValue)
                {
                    // Add both value and file params - prefer non-empty descriptions
                    if (!result.TryGetValue(p.Name, out var existing) ||
                        (string.IsNullOrEmpty(existing.Description) && !string.IsNullOrEmpty(p.XmlDocDescription)))
                    {
                        var ep = new ExposedParameter(p.Name, "string?", p.XmlDocDescription, "null");
                        if (result.TryGetValue(p.Name, out var prev))
                            ep.RequiredByActions.AddRange(prev.RequiredByActions);
                        result[p.Name] = ep;
                    }
                    if (isRequired)
                        result[p.Name].RequiredByActions.Add(method.ActionName);

                    var fileParamName = $"{p.Name}{p.FileSuffix}";
                    if (!result.ContainsKey(fileParamName))
                        result[fileParamName] = new ExposedParameter(fileParamName, "string?", $"Path to file containing {p.Name}", "null");
                }
                else if (p.IsFromString && p.IsEnum)
                {
                    var exposedName = p.ExposedName ?? p.Name;
                    if (!result.TryGetValue(exposedName, out var existing) ||
                        (string.IsNullOrEmpty(existing.Description) && !string.IsNullOrEmpty(p.XmlDocDescription)))
                    {
                        var ep = new ExposedParameter(exposedName, "string?", p.XmlDocDescription, "null");
                        if (result.TryGetValue(exposedName, out var prev))
                            ep.RequiredByActions.AddRange(prev.RequiredByActions);
                        result[exposedName] = ep;
                    }
                    if (isRequired)
                        result[exposedName].RequiredByActions.Add(method.ActionName);
                }
                else
                {
                    if (!result.TryGetValue(p.Name, out var existing) ||
                        (string.IsNullOrEmpty(existing.Description) && !string.IsNullOrEmpty(p.XmlDocDescription)))
                    {
                        var typeName = p.TypeName.EndsWith("?") ? p.TypeName : $"{p.TypeName}?";
                        var defaultVal = p.HasDefault ? p.DefaultValue : "null";
                        var ep = new ExposedParameter(p.Name, typeName, p.XmlDocDescription, defaultVal);
                        if (result.TryGetValue(p.Name, out var prev))
                            ep.RequiredByActions.AddRange(prev.RequiredByActions);
                        result[p.Name] = ep;
                    }
                    if (isRequired)
                        result[p.Name].RequiredByActions.Add(method.ActionName);
                }
            }
        }

        // Set total action count on all params
        var totalActions = info.Methods.Count;
        foreach (var ep in result.Values)
        {
            ep.TotalActionCount = totalActions;
        }

        return result.Values.ToList();
    }

    private static string BuildForwardArgs(MethodInfo method, List<ExposedParameter> allParams)
    {
        if (method.Parameters.Count == 0)
            return "";

        var args = new List<string>();
        foreach (var p in method.Parameters)
        {
            if (p.IsFileOrValue)
            {
                args.Add(p.Name);
                args.Add($"{p.Name}{p.FileSuffix}");
            }
            else if (p.IsFromString && p.IsEnum)
            {
                args.Add(p.ExposedName ?? p.Name);
            }
            else
            {
                args.Add(p.Name);
            }
        }

        return ", " + string.Join(", ", args);
    }

    private static string GenerateComparisonFile(ServiceInfo info)
    {
        var sb = new StringBuilder();

        sb.AppendLine("// <auto-generated />");
        sb.AppendLine("// This file is for COMPARISON ONLY - shows what the generator produces");
        sb.AppendLine("// Compare against existing manual code to validate generation is correct");
        sb.AppendLine();
        sb.AppendLine($"/*");
        sb.AppendLine($"GENERATED FROM: I{info.CategoryPascal}Commands");
        sb.AppendLine($"CATEGORY: {info.Category}");
        sb.AppendLine($"MCP TOOL: {info.McpToolName}");
        sb.AppendLine($"REQUIRES SESSION: {!info.NoSession}");
        sb.AppendLine();
        sb.AppendLine("METHODS:");

        foreach (var method in info.Methods)
        {
            sb.AppendLine($"  {method.MethodName}()");
            sb.AppendLine($"    -> action: \"{method.ActionName}\"");
            sb.AppendLine($"    -> command: \"{info.Category}.{method.ActionName}\"");
            sb.AppendLine($"    -> returns: {method.ReturnType}");
            if (method.Parameters.Count > 0)
            {
                sb.AppendLine($"    -> params:");
                foreach (var p in method.Parameters)
                {
                    var defaultStr = p.HasDefault ? $" = {p.DefaultValue}" : "";
                    sb.AppendLine($"         {p.TypeName} {p.Name}{defaultStr}");
                }
            }
            sb.AppendLine();
        }

        sb.AppendLine("EXPECTED MCP SWITCH CASES:");
        foreach (var method in info.Methods)
        {
            var enumValue = method.MethodName;
            if (method.Parameters.Count > 0)
            {
                sb.AppendLine($"  PowerQueryAction.{enumValue} => Forward{method.MethodName}(sessionId, ...),");
            }
            else
            {
                sb.AppendLine($"  PowerQueryAction.{enumValue} => ExcelToolsBase.ForwardToService(\"{info.Category}.{method.ActionName}\", sessionId),");
            }
        }
        sb.AppendLine();

        sb.AppendLine("EXPECTED CLI ARGS MAPPING:");
        foreach (var method in info.Methods)
        {
            if (method.Parameters.Count > 0)
            {
                var args = string.Join(", ", method.Parameters.Select(p => $"{p.Name} = settings.{StringHelper.ToPascalCase(p.Name)}"));
                sb.AppendLine($"  \"{method.ActionName}\" => new {{ {args} }},");
            }
            else
            {
                sb.AppendLine($"  \"{method.ActionName}\" => null,");
            }
        }

        sb.AppendLine("*/");

        return sb.ToString();
    }

    private static string GenerateCliManifest(List<ServiceInfo> categories)
    {
        var sb = new StringBuilder();

        sb.AppendLine("// <auto-generated />");
        sb.AppendLine("#nullable enable");
        sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment");
        sb.AppendLine();
        sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
        sb.AppendLine();
        sb.AppendLine("/// <summary>");
        sb.AppendLine("/// CLI category metadata generated from [ServiceCategory] interfaces.");
        sb.AppendLine("/// Used by CLI commands (ListActionsCommand) and the CliSettingsGenerator.");
        sb.AppendLine("/// </summary>");
        sb.AppendLine("public static class _CliCategoryMetadata");
        sb.AppendLine("{");
        sb.AppendLine("    /// <summary>List of all CLI command categories and their ServiceRegistry type names.</summary>");
        sb.AppendLine("    public static readonly (string CliCommandName, string RegistryTypeName, bool RequiresSession)[] Categories = new[]");
        sb.AppendLine("    {");

        foreach (var cat in categories.OrderBy(c => c.CategoryPascal))
        {
            var cliCommandName = cat.McpToolName.Replace("_", "");
            sb.AppendLine($"        (\"{cliCommandName}\", \"ServiceRegistry.{cat.CategoryPascal}\", {(cat.NoSession ? "false" : "true")}),");
        }

        sb.AppendLine("    };");
        sb.AppendLine();
        sb.AppendLine("    /// <summary>Maps CLI command names to their valid actions. Generated from [ServiceCategory] interfaces.</summary>");
        sb.AppendLine("    public static readonly System.Collections.Generic.Dictionary<string, System.Collections.Generic.IReadOnlyList<string>> ValidActionsByCommand = new()");
        sb.AppendLine("    {");

        foreach (var cat in categories.OrderBy(c => c.CategoryPascal))
        {
            var cliCommandName = cat.McpToolName.Replace("_", "");
            sb.AppendLine($"        [\"{cliCommandName}\"] = ServiceRegistry.{cat.CategoryPascal}.ValidActions,");
        }

        sb.AppendLine("    };");
        sb.AppendLine("}");

        return sb.ToString();
    }

    /// <summary>
    /// Generates a JSON manifest as a constant string for skill documentation generation.
    /// The Build.Tasks project extracts this JSON to generate SKILL.md files.
    /// </summary>
    private static string GenerateSkillManifest(List<ServiceInfo> categories)
    {
        var sb = new StringBuilder();

        sb.AppendLine("// <auto-generated />");
        sb.AppendLine("#nullable enable");
        sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment");
        sb.AppendLine();
        sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
        sb.AppendLine();
        sb.AppendLine("/// <summary>");
        sb.AppendLine("/// JSON manifest for skill file generation.");
        sb.AppendLine("/// Used by ExcelMcp.Build.Tasks to generate SKILL.md files from templates.");
        sb.AppendLine("/// </summary>");
        sb.AppendLine("internal static class _SkillManifest");
        sb.AppendLine("{");
        sb.AppendLine("    /// <summary>JSON manifest of all commands and parameters.</summary>");
        sb.AppendLine("    public const string Json = @\"");

        // Build JSON manually (no System.Text.Json in source generators)
        sb.AppendLine("{");
        sb.AppendLine("  \"\"commands\"\": [");

        var orderedCategories = categories.OrderBy(c => c.CategoryPascal).ToList();
        for (int i = 0; i < orderedCategories.Count; i++)
        {
            var cat = orderedCategories[i];
            var cliCommandName = cat.McpToolName.Replace("_", "");

            sb.AppendLine("    {");
            sb.AppendLine($"      \"\"name\"\": \"\"{cliCommandName}\"\",");
            sb.AppendLine($"      \"\"mcpTool\"\": \"\"{cat.McpToolName}\"\",");

            // Description from interface XML doc
            var description = (cat.XmlDocSummary ?? "")
                .Replace("\"", "\\\"\"")
                .Replace("\r", "")
                .Replace("\n", " ")
                .Trim();
            sb.AppendLine($"      \"\"description\"\": \"\"{description}\"\",");

            // Actions array
            sb.Append("      \"\"actions\"\": [");
            var actions = cat.Methods.Select(m => $"\"\"{m.ActionName}\"\"");
            sb.Append(string.Join(", ", actions));
            sb.AppendLine("],");

            // Parameters array with required-by-actions tracking
            sb.AppendLine("      \"\"parameters\"\": [");

            // Build required-by-actions map for each param
            var paramRequiredMap = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
            var totalActions = cat.Methods.Count;
            foreach (var method in cat.Methods)
            {
                foreach (var p in method.Parameters)
                {
                    var pName = p.ExposedName ?? StringHelper.ToKebabCase(p.Name);
                    var isRequired = p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"));
                    if (isRequired)
                    {
                        if (!paramRequiredMap.TryGetValue(pName, out var reqActions))
                        {
                            reqActions = new List<string>();
                            paramRequiredMap[pName] = reqActions;
                        }
                        reqActions.Add(method.ActionName);
                    }
                }
            }

            var distinctParams = cat.Methods
                .SelectMany(m => m.Parameters)
                .GroupBy(p => p.ExposedName ?? StringHelper.ToKebabCase(p.Name))
                .Select(g => g.First())
                .ToList();

            for (int j = 0; j < distinctParams.Count; j++)
            {
                var param = distinctParams[j];
                var paramName = StringHelper.ToKebabCase(param.ExposedName ?? param.Name);
                // JSON requires \" for quotes inside strings
                // In verbatim C# strings: \"" outputs \" (backslash + "" for one quote)
                var baseDescription = (param.XmlDocDescription ?? "")
                    .Replace("\r", "")
                    .Replace("\n", " ")
                    .Trim();

                // Append required-by-actions info
                if (paramRequiredMap.TryGetValue(paramName, out var requiredActions) && requiredActions.Count > 0)
                {
                    var suffix = requiredActions.Count == totalActions
                        ? "(required)"
                        : $"(required for: {string.Join(", ", requiredActions)})";
                    baseDescription = string.IsNullOrEmpty(baseDescription) ? suffix : $"{baseDescription} {suffix}";
                }

                var paramDescription = baseDescription
                    .Replace("\"", "\\\"\"");  // Escape quotes for JSON in verbatim string

                sb.AppendLine("        {");
                sb.AppendLine($"          \"\"name\"\": \"\"{paramName}\"\",");
                sb.AppendLine($"          \"\"description\"\": \"\"{paramDescription}\"\"");
                sb.Append("        }");
                sb.AppendLine(j < distinctParams.Count - 1 ? "," : "");
            }

            sb.AppendLine("      ]");
            sb.Append("    }");
            sb.AppendLine(i < orderedCategories.Count - 1 ? "," : "");
        }

        sb.AppendLine("  ],");
        sb.AppendLine($"  \"\"totalCommands\"\": {orderedCategories.Count},");
        sb.AppendLine($"  \"\"totalOperations\"\": {orderedCategories.Sum(c => c.Methods.Count)}");
        sb.AppendLine("}\";");
        sb.AppendLine("}");

        return sb.ToString();
    }

    // NOTE: Data models (ServiceInfo, MethodInfo, ParameterInfo) are now in
    // ExcelMcp.Generators.Shared and included as source files

    // =====================================================
    // Service Dispatch Generation
    // =====================================================

    /// <summary>
    /// Generates the shared dispatch helper methods (DeserializeArgs, ParseEnumValue, DispatchJsonOptions).
    /// Emitted once as ServiceRegistry.DispatchHelpers.g.cs.
    /// </summary>
    private static string GenerateDispatchHelpers()
    {
        var sb = new StringBuilder();

        sb.AppendLine("// <auto-generated />");
        sb.AppendLine("#nullable enable");
        sb.AppendLine("#pragma warning disable CS1591");
        sb.AppendLine();
        sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
        sb.AppendLine();
        sb.AppendLine("public static partial class ServiceRegistry");
        sb.AppendLine("{");
        sb.AppendLine("    /// <summary>");
        sb.AppendLine("    /// JSON options for service dispatch serialization.");
        sb.AppendLine("    /// Matches ServiceProtocol.JsonOptions (CamelCase, ignore nulls, string enums).");
        sb.AppendLine("    /// </summary>");
        sb.AppendLine("    internal static readonly System.Text.Json.JsonSerializerOptions DispatchJsonOptions = new()");
        sb.AppendLine("    {");
        sb.AppendLine("        PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,");
        sb.AppendLine("        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,");
        sb.AppendLine("        Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }");
        sb.AppendLine("    };");
        sb.AppendLine();
        sb.AppendLine("    /// <summary>");
        sb.AppendLine("    /// Deserializes args JSON into a typed class. Returns new instance if JSON is null/empty.");
        sb.AppendLine("    /// </summary>");
        sb.AppendLine("    public static T DeserializeArgs<T>(string? argsJson) where T : class, new()");
        sb.AppendLine("    {");
        sb.AppendLine("        if (string.IsNullOrEmpty(argsJson)) return new T();");
        sb.AppendLine("        return System.Text.Json.JsonSerializer.Deserialize<T>(argsJson, DispatchJsonOptions) ?? new T();");
        sb.AppendLine("    }");
        sb.AppendLine();
        sb.AppendLine("    /// <summary>");
        sb.AppendLine("    /// Parses an enum value from a string with kebab-case and snake_case support.");
        sb.AppendLine("    /// Returns defaultValue if the string is null, empty, or cannot be parsed.");
        sb.AppendLine("    /// </summary>");
        sb.AppendLine("    internal static T ParseEnumValue<T>(string? value, T defaultValue) where T : struct, System.Enum");
        sb.AppendLine("    {");
        sb.AppendLine("        if (string.IsNullOrEmpty(value)) return defaultValue;");
        sb.AppendLine("        var cleaned = value.Replace(\"-\", \"\").Replace(\"_\", \"\");");
        sb.AppendLine("        return System.Enum.TryParse<T>(cleaned, ignoreCase: true, out var result) ? result : defaultValue;");
        sb.AppendLine("    }");
        sb.AppendLine();
        sb.AppendLine("    /// <summary>");
        sb.AppendLine("    /// Deserializes a nested collection (e.g., List&lt;List&lt;object?&gt;&gt;) from JSON.");
        sb.AppendLine("    /// Auto-wraps a flat 1D array into a 2D array when needed.");
        sb.AppendLine("    /// For example, [\"a\",\"b\"] is treated as [[\"a\",\"b\"]] (single row).");
        sb.AppendLine("    /// Supports stdin sentinel: if json is \"-\", reads JSON from Console.In.");
        sb.AppendLine("    /// </summary>");
        sb.AppendLine("    internal static T DeserializeNestedCollection<T>(string json) where T : class");
        sb.AppendLine("    {");
        sb.AppendLine("        // Stdin sentinel: if json == \"-\", read from Console.In.");
        sb.AppendLine("        // This allows piping JSON to avoid PowerShell argument quoting issues:");
        sb.AppendLine("        //   echo '[[\"value\",1]]' | excelcli range set-values --values -");
        sb.AppendLine("        if (json.Trim() == \"-\")");
        sb.AppendLine("        {");
        sb.AppendLine("            json = Console.In.ReadToEnd().Trim();");
        sb.AppendLine("        }");
        sb.AppendLine("        try");
        sb.AppendLine("        {");
        sb.AppendLine("            return System.Text.Json.JsonSerializer.Deserialize<T>(json)");
        sb.AppendLine("                ?? throw new System.Text.Json.JsonException(\"Deserialization returned null\");");
        sb.AppendLine("        }");
        sb.AppendLine("        catch (System.Text.Json.JsonException)");
        sb.AppendLine("        {");
        sb.AppendLine("            // Auto-wrap 1D array to 2D: [\"a\",\"b\"] → [[\"a\",\"b\"]]");
        sb.AppendLine("            var trimmed = json.Trim();");
        sb.AppendLine("            if (trimmed.StartsWith(\"[\") && !trimmed.StartsWith(\"[[\"))");
        sb.AppendLine("            {");
        sb.AppendLine("                var wrapped = \"[\" + trimmed + \"]\";");
        sb.AppendLine("                try");
        sb.AppendLine("                {");
        sb.AppendLine("                    return System.Text.Json.JsonSerializer.Deserialize<T>(wrapped)");
        sb.AppendLine("                        ?? throw new System.Text.Json.JsonException(\"Deserialization returned null\");");
        sb.AppendLine("                }");
        sb.AppendLine("                catch { /* fall through to error */ }");
        sb.AppendLine("            }");
        sb.AppendLine("            throw new System.ArgumentException(");
        sb.AppendLine("                $\"Invalid JSON for nested collection. Expected 2D array (e.g., [[\\\"a\\\",\\\"b\\\"],[\\\"c\\\",\\\"d\\\"]]) or 1D array (auto-wrapped to single row). Got: {json}\" +");
        sb.AppendLine("                \" Tip: PowerShell strips double-quotes from native executable arguments. Use --values-file <path> to pass JSON from a file, or pipe JSON and use --values - (e.g., echo '[[...]]' | excelcli ... --values -).\");");
        sb.AppendLine("        }");
        sb.AppendLine("    }");
        sb.AppendLine();
        sb.AppendLine("    /// <summary>");
        sb.AppendLine("    /// Deserializes a simple list (e.g., List&lt;string&gt;) from a JSON array string.");
        sb.AppendLine("    /// Used for CLI parameters where LLMs pass JSON arrays instead of repeated flags.");
        sb.AppendLine("    /// </summary>");
        sb.AppendLine("    internal static T? DeserializeList<T>(string? json) where T : class");
        sb.AppendLine("    {");
        sb.AppendLine("        if (string.IsNullOrWhiteSpace(json)) return null;");
        sb.AppendLine("        return System.Text.Json.JsonSerializer.Deserialize<T>(json)");
        sb.AppendLine("            ?? throw new System.Text.Json.JsonException($\"Failed to deserialize list from JSON: {json}\");");
        sb.AppendLine("    }");
        sb.AppendLine("}");

        return sb.ToString();
    }

    /// <summary>
    /// Generates the DispatchToCore method for a service category.
    /// This method routes parsed actions to Core command methods,
    /// replacing hand-written Handle*CommandAsync methods in ExcelMcpService.
    /// </summary>
    private static string GenerateServiceDispatch(ServiceInfo info, string interfaceFullName)
    {
        var sb = new StringBuilder();

        sb.AppendLine("// <auto-generated />");
        sb.AppendLine("#nullable enable");
        sb.AppendLine("#pragma warning disable CS1591");
        sb.AppendLine();
        sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
        sb.AppendLine();
        sb.AppendLine("public static partial class ServiceRegistry");
        sb.AppendLine("{");
        sb.AppendLine($"    public static partial class {info.CategoryPascal}");
        sb.AppendLine("    {");
        sb.AppendLine("        /// <summary>");
        sb.AppendLine($"        /// Dispatches a {info.CategoryPascal} action to the Core command method.");
        sb.AppendLine("        /// Returns serialized JSON result, or null for void operations.");
        sb.AppendLine("        /// </summary>");
        sb.AppendLine("        public static string? DispatchToCore(");
        sb.AppendLine($"            {interfaceFullName} commands,");
        sb.AppendLine($"            {info.CategoryPascal}Action action,");
        if (!info.NoSession)
            sb.AppendLine("            Sbroenne.ExcelMcp.ComInterop.Session.IExcelBatch batch,");
        sb.AppendLine("            string? argsJson)");
        sb.AppendLine("        {");
        sb.AppendLine("            switch (action)");
        sb.AppendLine("            {");

        foreach (var method in info.Methods)
        {
            GenerateDispatchCase(sb, info, method);
        }

        sb.AppendLine("                default:");
        sb.AppendLine($"                    throw new System.ArgumentException($\"Unknown {info.CategoryPascal} action: {{action}}\");");
        sb.AppendLine("            }");
        sb.AppendLine("        }");
        sb.AppendLine("    }");
        sb.AppendLine("}");

        return sb.ToString();
    }

    /// <summary>
    /// Generates a single case block within the DispatchToCore switch statement.
    /// Handles args deserialization, enum parsing, and Core method invocation.
    /// </summary>
    private static void GenerateDispatchCase(StringBuilder sb, ServiceInfo info, MethodInfo method)
    {
        sb.AppendLine($"                case {info.CategoryPascal}Action.{method.MethodName}:");
        sb.AppendLine("                {");

        // Deserialize args if method has parameters
        if (method.Parameters.Count > 0)
        {
            sb.AppendLine($"                    var args = DeserializeArgs<{method.MethodName}Args>(argsJson);");
        }

        // Parse enum parameters into local variables
        foreach (var p in method.Parameters.Where(p => p.IsEnum))
        {
            var enumType = p.TypeName.TrimEnd('?');
            var propName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
            var pascalProp = StringHelper.ToPascalCase(propName);
            var isNullableEnum = p.TypeName.EndsWith("?");

            if (isNullableEnum)
            {
                sb.AppendLine($"                    var {p.Name} = !string.IsNullOrEmpty(args.{pascalProp}) ? ({enumType}?)ParseEnumValue<{enumType}>(args.{pascalProp}, default) : null;");
            }
            else
            {
                var defaultExpr = (p.HasDefault && p.DefaultValue != null) ? p.DefaultValue : $"default({enumType})";
                sb.AppendLine($"                    var {p.Name} = ParseEnumValue<{enumType}>(args.{pascalProp}, {defaultExpr});");
            }
        }

        // Parse [FromString] non-enum, non-string parameters into local variables
        // These are stored as string? in args but need type conversion (e.g., bool?, TimeSpan?)
        foreach (var p in method.Parameters.Where(p => p.IsFromString && !p.IsEnum && !StringHelper.IsStringType(p.TypeName)))
        {
            var propName = p.ExposedName ?? p.Name;
            var pascalProp = StringHelper.ToPascalCase(propName);
            var isNullable = p.TypeName.EndsWith("?");
            var baseType = p.TypeName.TrimEnd('?');

            if (isNullable)
            {
                sb.AppendLine($"                    var {p.Name} = !string.IsNullOrEmpty(args.{pascalProp}) ? ({p.TypeName}){baseType}.Parse(args.{pascalProp}) : null;");
            }
            else if (p.HasDefault && p.DefaultValue != null)
            {
                sb.AppendLine($"                    var {p.Name} = !string.IsNullOrEmpty(args.{pascalProp}) ? {baseType}.Parse(args.{pascalProp}) : {p.DefaultValue};");
            }
            else
            {
                sb.AppendLine($"                    var {p.Name} = {baseType}.Parse(args.{pascalProp}!);");
            }
        }

        // Build method call argument list
        var callArgs = new List<string>();
        // Only pass batch if the method actually has an IExcelBatch parameter
        if (!info.NoSession && method.HasBatchParameter)
            callArgs.Add("batch");

        foreach (var p in method.Parameters)
        {
            if (p.IsEnum || (p.IsFromString && !StringHelper.IsStringType(p.TypeName)))
            {
                // Already parsed to local variable
                callArgs.Add(p.Name);
            }
            else
            {
                var propName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
                var pascalProp = StringHelper.ToPascalCase(propName);
                callArgs.Add(GetDispatchCallExpression(p, pascalProp));
            }
        }

        // Inject ambient progress context LAST (matches Core method signature where IProgress is the last parameter)
        if (method.HasProgressParameter)
            callArgs.Add("Sbroenne.ExcelMcp.ComInterop.ProgressContext.Current");

        var isVoid = method.ReturnType == "void";
        if (isVoid)
        {
            sb.AppendLine($"                    commands.{method.MethodName}({string.Join(", ", callArgs)});");
            sb.AppendLine("                    return null;");
        }
        else
        {
            sb.AppendLine($"                    var result = commands.{method.MethodName}({string.Join(", ", callArgs)});");
            sb.AppendLine("                    return System.Text.Json.JsonSerializer.Serialize(result, DispatchJsonOptions);");
        }

        sb.AppendLine("                }");
    }

    /// <summary>
    /// Gets the C# expression to pass a non-enum parameter from the args object to the Core method.
    /// Handles nullable unwrapping, default values, and null-forgiving operators.
    /// </summary>
    private static string GetDispatchCallExpression(ParameterInfo p, string pascalProp)
    {
        var typeName = p.TypeName;

        // If Core type is already nullable, pass as-is from args
        if (typeName.EndsWith("?"))
            return $"args.{pascalProp}";

        // Non-nullable Core type. Args property is nullable (MakeArgsPropertyType adds ?).
        // Need to provide a non-null value.

        if (p.HasDefault && p.DefaultValue != null)
        {
            // Use default value when args property is null
            return $"args.{pascalProp} ?? {p.DefaultValue}";
        }

        // Required/non-nullable parameter without default.
        // For value types: use ?? default(T) to avoid nullable unwrap issues
        // For reference types: use ! (null-forgiving)
        if (IsKnownValueType(typeName))
        {
            return $"args.{pascalProp} ?? default({typeName})";
        }

        // Reference type: null-forgiving
        return $"args.{pascalProp}!";
    }

    /// <summary>
    /// Checks if a type name represents a known value type (struct).
    /// Used to determine whether to use ?? default(T) vs ! for nullable unwrapping.
    /// </summary>
    private static bool IsKnownValueType(string typeName)
    {
        return typeName is "int" or "long" or "short" or "byte" or "sbyte"
            or "float" or "double" or "decimal" or "bool" or "char"
            || typeName.Contains("TimeSpan");
    }
}
