# frozen_string_literal: true

module Dispatch
  module Adapter
    class Claude < Base
      module RequestBuilder
        # Converts an Array<Dispatch::Adapter::ToolDefinition> (or plain Hashes)
        # into the Anthropic tools wire format:
        #   [{name:, description:, input_schema:, strict?:}, …]
        #
        # Key transformations applied here:
        #   - Tool names are prefixed with "proxy_" when OAuth is active.
        #   - Unsupported JSON-Schema fields are removed (maxItems on objects,
        #     patternProperties on objects, minItems on objects, and minItems
        #     on arrays when the value is not 0 or 1).
        #   - additionalProperties: false is injected on every object node.
        #   - Tools whose base name is in STRICT_ALLOWLIST get strict: true when
        #     the schema fits within the strict-mode parameter budgets.
        #   - disable_strict: true or ENV["CLAUDE_NO_STRICT"] suppresses all
        #     strict: true annotations.
        module Tools
          UNSUPPORTED_FIELDS         = %w[maxItems patternProperties].freeze
          STRICT_ALLOWLIST           = %w[bash python edit find].freeze
          MAX_STRICT_TOOLS           = 20
          MAX_STRICT_OPTIONAL_PARAMS = 24
          MAX_STRICT_UNION_PARAMS    = 16

          module_function

          # Convert tool definitions to the Anthropic wire format.
          #
          # @param tools [Array<ToolDefinition, Hash>]
          # @param is_oauth [Boolean]
          # @param disable_strict [Boolean]
          # @return [Array<Hash>]
          def build(tools, is_oauth: false, disable_strict: false)
            return [] if tools.nil? || tools.empty?

            tools.map do |tool|
              convert_tool(tool, is_oauth: is_oauth, disable_strict: disable_strict)
            end
          end

          def convert_tool(tool, is_oauth:, disable_strict:)
            orig_name   = extract_name(tool)
            description = extract_description(tool)
            parameters  = extract_parameters(tool)

            # Apply proxy_ prefix for OAuth mode
            wire_name = is_oauth ? Cloaking.apply_prefix(orig_name) : orig_name

            # Deep-clone and sanitise the schema (always applied)
            raw_schema      = parameters || { "type" => "object", "properties" => {} }
            sanitised       = sanitise_schema(deep_clone(raw_schema))

            wire = {
              name: wire_name,
              description: description,
              input_schema: sanitised
            }

            # Optionally upgrade to strict mode
            if eligible_for_strict?(orig_name, disable_strict)
              budget = count_budget(sanitised)
              if budget[:optional] <= MAX_STRICT_OPTIONAL_PARAMS &&
                 budget[:union]    <= MAX_STRICT_UNION_PARAMS
                normalised = normalise_for_strict(deep_clone(sanitised))
                if normalised
                  wire[:input_schema] = normalised
                  wire[:strict]       = true
                end
              end
            end

            wire
          end

          # ---------------------------------------------------------------------------
          # Field extraction helpers
          # ---------------------------------------------------------------------------

          def extract_name(tool)
            if tool.respond_to?(:name)
              tool.name.to_s
            else
              (tool[:name] || tool["name"]).to_s
            end
          end

          def extract_description(tool)
            if tool.respond_to?(:description)
              tool.description
            else
              tool[:description] || tool["description"]
            end
          end

          def extract_parameters(tool)
            if tool.respond_to?(:parameters)
              tool.parameters
            else
              tool[:parameters] || tool["parameters"]
            end
          end

          # ---------------------------------------------------------------------------
          # Deep clone
          # ---------------------------------------------------------------------------

          def deep_clone(obj)
            case obj
            when Hash  then obj.each_with_object({}) { |(k, v), h| h[k] = deep_clone(v) }
            when Array then obj.map { |v| deep_clone(v) }
            else obj
            end
          end

          # ---------------------------------------------------------------------------
          # Schema sanitisation (applied to ALL tools)
          # ---------------------------------------------------------------------------

          # Walk the schema recursively. On every object node:
          #   - Strip UNSUPPORTED_FIELDS and minItems
          #   - Set additionalProperties: false
          # On every array node:
          #   - Strip minItems when value is not 0 or 1
          # Normalise all hash keys to strings.
          def sanitise_schema(schema)
            return schema unless schema.is_a?(Hash)

            schema = schema.transform_keys(&:to_s)

            # Recurse first, so children are clean before we inspect type
            schema = recurse_sub_schemas(schema)

            type = schema["type"]

            if type == "object"
              UNSUPPORTED_FIELDS.each { |f| schema.delete(f) }
              schema.delete("minItems")
              schema["additionalProperties"] = false unless schema.key?("additionalProperties")
            end

            schema.delete("minItems") if type == "array" && schema.key?("minItems") && ![0, 1].include?(schema["minItems"])

            schema
          end

          # Recurse into all sub-schema positions.
          def recurse_sub_schemas(schema)
            if schema["properties"].is_a?(Hash)
              schema["properties"] = schema["properties"].each_with_object({}) do |(k, v), h|
                h[k.to_s] = sanitise_schema(v)
              end
            end

            %w[items not additionalProperties].each do |key|
              schema[key] = sanitise_schema(schema[key]) if schema[key].is_a?(Hash)
            end

            schema["items"] = schema["items"].map { |v| sanitise_schema(v) } if schema["items"].is_a?(Array)

            %w[anyOf oneOf allOf].each do |key|
              next unless schema[key].is_a?(Array)

              schema[key] = schema[key].map { |v| sanitise_schema(v) }
            end

            schema
          end

          # ---------------------------------------------------------------------------
          # Strict-mode eligibility
          # ---------------------------------------------------------------------------

          # Returns true when this tool is a candidate for strict: true.
          def eligible_for_strict?(orig_name, disable_strict)
            return false if disable_strict
            return false if ENV["CLAUDE_NO_STRICT"]

            base = Cloaking.strip_prefix(orig_name)
            STRICT_ALLOWLIST.include?(base.downcase)
          end

          # ---------------------------------------------------------------------------
          # Budget counting (on the sanitised schema, before normalisation)
          # ---------------------------------------------------------------------------

          # Count:
          #   :optional — number of properties NOT in required across all object nodes
          #   :union    — number of anyOf/oneOf/allOf branch-schemas across all nodes
          def count_budget(schema, acc = { optional: 0, union: 0 })
            return acc unless schema.is_a?(Hash)

            schema = schema.transform_keys(&:to_s)

            # Count union variants
            %w[anyOf oneOf allOf].each do |key|
              next unless schema[key].is_a?(Array)

              acc[:union] += schema[key].length
              schema[key].each { |sub| count_budget(sub, acc) }
            end

            # Count optional properties in object nodes
            if schema["type"] == "object"
              props    = (schema["properties"] || {}).keys.map(&:to_s)
              required = Array(schema["required"] || []).map(&:to_s)
              acc[:optional] += (props - required).length

              (schema["properties"] || {}).each_value { |v| count_budget(v, acc) }
            end

            # Recurse into array items
            items = schema["items"]
            if items.is_a?(Hash)
              count_budget(items, acc)
            elsif items.is_a?(Array)
              items.each { |v| count_budget(v, acc) }
            end

            # Recurse into not / additionalProperties (if schema)
            count_budget(schema["not"], acc) if schema["not"].is_a?(Hash)

            acc
          end

          # ---------------------------------------------------------------------------
          # Strict-mode normalisation
          # ---------------------------------------------------------------------------

          # Walk every object node and:
          #   1. Move all properties into required
          #   2. Wrap optional properties in anyOf: [{...}, {type: "null"}]
          #   3. Set additionalProperties: false
          #
          # Returns the mutated (deep-cloned) schema, or nil if normalisation fails.
          def normalise_for_strict(schema)
            return schema unless schema.is_a?(Hash)

            schema = schema.transform_keys(&:to_s)

            if schema["type"] == "object"
              props    = schema["properties"] || {}
              required = Array(schema["required"] || []).map(&:to_s)
              all_keys = props.keys.map(&:to_s)
              optional = all_keys - required

              # Make optional properties nullable
              optional.each do |prop|
                props[prop] = make_nullable(props[prop]) if props[prop].is_a?(Hash)
              end

              # Recursively normalise nested object properties
              props.each do |prop, sub|
                next unless sub.is_a?(Hash)

                normalised_sub = normalise_for_strict(sub)
                return nil if normalised_sub.nil?

                props[prop] = normalised_sub
              end

              schema["required"]             = all_keys
              schema["properties"]           = props
              schema["additionalProperties"] = false
            end

            # Recurse into array items
            items = schema["items"]
            if items.is_a?(Hash)
              normalised_items = normalise_for_strict(items)
              return nil if normalised_items.nil?

              schema["items"] = normalised_items
            elsif items.is_a?(Array)
              normalised_items = items.map { |v| normalise_for_strict(v) }
              return nil if normalised_items.any?(&:nil?)

              schema["items"] = normalised_items
            end

            # Recurse into anyOf / oneOf / allOf branches
            %w[anyOf oneOf allOf].each do |key|
              next unless schema[key].is_a?(Array)

              normalised = schema[key].map { |v| normalise_for_strict(v) }
              return nil if normalised.any?(&:nil?)

              schema[key] = normalised
            end

            schema
          end

          # Wrap a schema so it also allows null.
          #
          # If the schema already uses anyOf, append {type: "null"} (once).
          # Otherwise convert to anyOf: [{original}, {type: "null"}].
          def make_nullable(schema)
            return schema unless schema.is_a?(Hash)

            null_schema = { "type" => "null" }

            if schema.key?("anyOf")
              branches = schema["anyOf"]
              return schema if branches.is_a?(Array) &&
                               branches.any? { |s| s.is_a?(Hash) && s["type"] == "null" }

              schema["anyOf"] = Array(branches) + [null_schema]
              return schema
            end

            # Simple schema with explicit type (or no type)
            existing_type = schema["type"]
            return schema if existing_type == "null"

            return schema if existing_type.is_a?(Array) && existing_type.include?("null")

            { "anyOf" => [schema, null_schema] }
          end
        end
      end
    end
  end
end
