# SPDX-FileCopyrightText: 2020 Zach Daniel
#
# SPDX-License-Identifier: MIT

if Code.ensure_loaded?(OpenApiSpex) do
  defmodule AshJsonApi.OpenApi do
    @moduledoc """
    Provides functions for generating schemas and operations for OpenApi definitions.

    Add `open_api_spex` to your `mix.exs` deps for the required struct definitions.

    ## Example

        defmodule MyApp.OpenApi do
          alias OpenApiSpex.{OpenApi, Info, Server, Components}

          def spec do
            %OpenApi{
              info: %Info{
                title: "MyApp JSON API",
                version: "1.1"
              },
              servers: [
                Server.from_endpoint(MyAppWeb.Endpoint)
              ],
              paths: AshJsonApi.OpenApi.paths(MyApp.Api),
              tags: AshJsonApi.OpenApi.tags(MyApp.Api),
              components: %Components{
                responses: AshJsonApi.OpenApi.responses(),
                schemas: AshJsonApi.OpenApi.schemas(MyApp.Api)
              }
            }
          end
        end
    """
    alias Ash.Query.Aggregate
    alias AshJsonApi.Resource.Route
    alias Ash.Resource.{Actions, Relationships}

    alias OpenApiSpex.{
      Info,
      MediaType,
      OpenApi,
      Operation,
      Parameter,
      PathItem,
      Paths,
      Reference,
      RequestBody,
      Response,
      Schema,
      SecurityScheme,
      Server,
      Tag
    }

    require Logger

    @typep content_type_format() :: :json | :multipart
    @typep acc() :: map()

    @doc """
    Creates an empty accumulator for schema generation.
    """
    def empty_acc do
      %{schemas: %{}, seen_non_schema_types: [], seen_input_types: []}
    end

    @dialyzer {:nowarn_function, {:action_description, 3}}
    @dialyzer {:nowarn_function, {:relationship_resource_identifiers, 1}}
    @dialyzer {:nowarn_function, {:resource_object_schema, 3}}

    def spec(opts \\ [], conn \\ nil) do
      domains = List.wrap(opts[:domain] || opts[:domains])
      title = opts[:open_api_title] || "Open API Specification"
      version = opts[:open_api_version] || "1.1"

      servers =
        cond do
          is_list(opts[:open_api_servers]) ->
            Enum.map(opts[:open_api_servers], &%OpenApiSpex.Server{url: &1})

          opts[:phoenix_endpoint] != nil ->
            [Server.from_endpoint(opts[:phoenix_endpoint])]

          true ->
            []
        end

      # Create accumulator once at the top level
      acc = empty_acc()
      # Generate paths first to accumulate schemas
      {paths_definitions, acc} = AshJsonApi.OpenApi.paths(domains, domains, opts, acc)
      # Generate schemas last to get all accumulated schemas
      schema_definitions = AshJsonApi.OpenApi.schemas(domains, acc)

      %OpenApi{
        info: %Info{
          title: title,
          version: version
        },
        servers: servers,
        paths: paths_definitions,
        tags: AshJsonApi.OpenApi.tags(domains),
        components: %{
          responses: AshJsonApi.OpenApi.responses(),
          schemas: schema_definitions,
          securitySchemes: %{
            "bearerAuth" => %SecurityScheme{
              type: "http",
              description: "JWT for bearer authentication",
              scheme: "bearer",
              bearerFormat: "JWT"
            }
          }
        },
        security: [
          %{
            # bearer auth security applies to all operations
            "bearerAuth" => []
          }
        ]
      }
      |> modify(conn, opts)
    end

    defp modify(spec, conn, opts) do
      case opts[:modify_open_api] do
        modify when is_function(modify) ->
          modify.(spec, conn, opts)

        {m, f, a} ->
          apply(m, f, [spec, conn, opts | a])

        _ ->
          spec
      end
    end

    @doc """
    Common responses to include in the API Spec.
    """
    @spec responses() :: OpenApiSpex.Components.responses_map()
    def responses do
      %{
        "errors" => %Response{
          description: "General Error",
          content: %{
            "application/vnd.api+json" => %MediaType{
              schema: %Reference{"$ref": "#/components/schemas/errors"}
            }
          }
        }
      }
    end

    @doc """
    Resource schemas to include in the API spec.
    """
    @spec schemas(domain :: module | [module], acc :: acc()) :: %{String.t() => Schema.t()}
    def schemas(domains, acc) when is_list(domains) do
      all_resources_requiring_filter_schemas =
        all_resources_requiring_filter_schemas(domains)

      {final_schemas, final_acc} =
        domains
        |> Enum.reduce({base_definitions(), acc}, fn domain, {definitions, acc} ->
          {resource_schemas, acc} =
            domain
            |> resources()
            |> Enum.reduce({[], acc}, fn resource, {schemas, acc} ->
              {schema, acc} = resource_object_schema(resource, nil, acc)
              schema_entry = {AshJsonApi.Resource.Info.type(resource), schema}
              {[schema_entry | schemas], acc}
            end)

          resource_schemas = Enum.reverse(resource_schemas)

          {filter_schemas, final_acc} =
            all_resources_requiring_filter_schemas
            |> Enum.reduce({[], acc}, fn resource, {schemas, acc} ->
              {resource_schemas, acc} = resource_filter_schemas(domains, resource, acc)
              {schemas ++ resource_schemas, acc}
            end)

          all_schemas = resource_schemas ++ filter_schemas
          {Map.merge(definitions, Map.new(all_schemas)), final_acc}
        end)

      Map.merge(final_schemas, final_acc.schemas)
    end

    def schemas(domain) do
      schemas(List.wrap(domain))
    end

    defp all_resources_requiring_filter_schemas(domains) do
      domains
      |> Enum.flat_map(&Ash.Domain.Info.resources/1)
      |> Enum.reject(&Enum.empty?(AshJsonApi.Resource.Info.routes(&1, domains)))
      |> with_all_related_resources()
      |> Enum.filter(fn resource ->
        AshJsonApi.Resource.Info.type(resource) &&
          AshJsonApi.Resource.Info.derive_filter?(resource)
      end)
    end

    defp with_all_related_resources(resources, checked \\ []) do
      resources
      |> Enum.reject(&(&1 in checked))
      |> Enum.flat_map(&Ash.Resource.Info.public_relationships/1)
      |> Enum.map(& &1.destination)
      |> Enum.reject(&(&1 in resources))
      |> case do
        [] ->
          resources

        new_destinations ->
          with_all_related_resources(resources ++ new_destinations, checked ++ resources)
      end
    end

    def define_filter?(domains, resource) do
      if AshJsonApi.Resource.Info.derive_filter?(resource) do
        something_relates_to?(domains, resource) || has_index_route?(domains, resource)
      else
        false
      end
    end

    defp something_relates_to?(domains, resource) do
      domains
      |> Stream.flat_map(&Ash.Domain.Info.resources/1)
      |> Stream.reject(&Enum.empty?(AshJsonApi.Resource.Info.routes(&1, domains)))
      |> Stream.flat_map(&Ash.Resource.Info.relationships/1)
      |> Stream.filter(& &1.public?)
      |> Enum.any?(&(&1.destination == resource))
    end

    defp has_index_route?(domains, resource) do
      resource
      |> AshJsonApi.Resource.Info.routes(domains)
      |> Enum.any?(fn route ->
        route.type == :index && read_action?(resource, route) && route.derive_filter?
      end)
    end

    defp read_action?(resource, route) do
      action = Ash.Resource.Info.action(resource, route.action)

      action && action.type == :read
    end

    defp resource_filter_schemas(domains, resource, acc) do
      {field_types, acc} = filter_field_types(resource, acc)

      schemas =
        [
          {
            "#{AshJsonApi.Resource.Info.type(resource)}-filter",
            %Schema{
              type: :deepObject,
              properties: resource_filter_fields(resource, domains),
              example: "",
              additionalProperties: false,
              description: "Filters the query to results matching the given filter object"
            }
          }
        ] ++ field_types

      {schemas, acc}
    end

    @spec base_definitions() :: %{String.t() => Schema.t()}
    defp base_definitions do
      %{
        "links" => %Schema{
          type: :object,
          additionalProperties: %Reference{"$ref": "#/components/schemas/link"}
        },
        "link" => %Schema{
          description:
            "A link MUST be represented as either: a string containing the link's URL or a link object.",
          type: :string
        },
        "errors" => %Schema{
          type: :array,
          items: %Reference{
            "$ref": "#/components/schemas/error"
          },
          uniqueItems: true
        },
        "error" => %Schema{
          type: :object,
          properties: %{
            id: %Schema{
              description: "A unique identifier for this particular occurrence of the problem.",
              type: :string
            },
            links: %Reference{
              "$ref": "#/components/schemas/links"
            },
            status: %Schema{
              description:
                "The HTTP status code applicable to this problem, expressed as a string value.",
              type: :string
            },
            code: %Schema{
              description: "An application-specific error code, expressed as a string value.",
              type: :string
            },
            title: %Schema{
              description:
                "A short, human-readable summary of the problem. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization.",
              type: :string
            },
            detail: %Schema{
              description:
                "A human-readable explanation specific to this occurrence of the problem.",
              type: :string
            },
            source: %Schema{
              type: :object,
              properties: %{
                pointer: %Schema{
                  description:
                    "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].",
                  type: :string
                },
                parameter: %Schema{
                  description: "A string indicating which query parameter caused the error.",
                  type: :string
                }
              }
            }
          },
          additionalProperties: false
        }
      }
    end

    defp resources(domain) do
      domain
      |> Ash.Domain.Info.resources()
      |> Enum.filter(&AshJsonApi.Resource.Info.type(&1))
    end

    defp resource_object_schema(resource, fields, acc) do
      {attributes_schema, acc} = attributes(resource, fields, acc)

      schema = %Schema{
        description:
          Ash.Resource.Info.description(resource) ||
            "A \"Resource object\" representing a #{AshJsonApi.Resource.Info.type(resource)}",
        type: :object,
        required: [:type, :id],
        properties: %{
          type: %Schema{type: :string},
          id: %{type: :string},
          attributes: attributes_schema,
          relationships: relationships(resource)
        },
        additionalProperties: false
      }

      {schema, acc}
    end

    @spec attributes(resource :: Ash.Resource.t(), fields :: nil | list(atom), acc :: acc) ::
            {Schema.t(), acc}
    defp attributes(resource, fields, acc) do
      fields =
        fields || AshJsonApi.Resource.Info.default_fields(resource) ||
          Enum.map(Ash.Resource.Info.public_attributes(resource), & &1.name)

      {properties, acc} = resource_attributes(resource, fields, :json, acc)

      schema =
        %Schema{
          description: "An attributes object for a #{AshJsonApi.Resource.Info.type(resource)}",
          type: :object,
          properties: properties,
          required: required_attributes(resource),
          additionalProperties: false
        }
        |> add_null_for_non_required()

      {schema, acc}
    end

    defp required_attributes(resource) do
      resource
      |> Ash.Resource.Info.public_attributes()
      |> Enum.reject(&(&1.allow_nil? || AshJsonApi.Resource.only_primary_key?(resource, &1.name)))
      |> Enum.map(& &1.name)
    end

    @spec resource_attributes(
            resource :: module,
            fields :: nil | list(atom),
            format :: content_type_format(),
            acc :: acc,
            hide_pkeys? :: boolean()
          ) :: {%{atom => Schema.t()}, acc}
    defp resource_attributes(resource, fields, format, acc, hide_pkeys? \\ true) do
      resource
      |> Ash.Resource.Info.public_attributes()
      |> Enum.concat(Ash.Resource.Info.public_calculations(resource))
      |> Enum.concat(
        Ash.Resource.Info.public_aggregates(resource)
        |> AshJsonApi.JsonSchema.set_aggregate_constraints(resource)
      )
      |> Enum.map(fn
        %Ash.Resource.Aggregate{} = agg ->
          field =
            if agg.field do
              related = Ash.Resource.Info.related(resource, agg.relationship_path)
              Ash.Resource.Info.field(related, agg.field)
            end

          field_type =
            if field do
              field.type
            end

          field_constraints =
            if field do
              field.constraints
            end

          {:ok, type, constraints} =
            Aggregate.kind_to_type(agg.kind, field_type, field_constraints)

          type = Ash.Type.get_type(type)

          allow_nil? =
            is_nil(Ash.Query.Aggregate.default_value(agg.kind))

          %{
            name: agg.name,
            description: agg.description,
            type: type,
            constraints: constraints,
            allow_nil?: allow_nil?
          }

        other ->
          other
      end)
      |> then(fn keys ->
        if hide_pkeys? do
          Enum.reject(keys, &AshJsonApi.Resource.only_primary_key?(resource, &1.name))
        else
          keys
        end
      end)
      |> Enum.reduce({%{}, acc}, fn attr, {attrs, acc} ->
        {schema, acc} = resource_attribute_type(attr, resource, acc, format)

        schema =
          schema
          |> with_attribute_description(attr)
          |> with_attribute_nullability(attr)
          |> with_comment_on_included(attr, fields)

        {Map.put(attrs, attr.name, schema), acc}
      end)
    end

    defp with_comment_on_included(%Schema{} = schema, attr, fields) do
      new_description =
        if is_nil(fields) || attr.name in fields do
          case schema.description do
            nil ->
              "Field included by default."

            description ->
              if String.ends_with?(description, ["!", "."]) do
                description <> " Field included by default."
              else
                description <> ". Field included by default."
              end
          end
        else
          schema.description
        end

      %{schema | description: new_description}
    end

    defp with_comment_on_included(schema, attr, fields) do
      key = if Map.has_key?(schema, :description), do: :description, else: "description"

      new_description =
        if is_nil(fields) || attr.name in fields do
          case Map.get(schema, key) do
            nil ->
              "Field included by default."

            description ->
              if String.ends_with?(description, ["!", "."]) do
                description <> " Field included by default."
              else
                description <> ". Field included by default."
              end
          end
        else
          Map.get(schema, key) || ""
        end

      Map.put(schema, key, new_description)
    end

    defp with_attribute_nullability(%Schema{type: nil} = schema, _), do: schema

    defp with_attribute_nullability(%Schema{} = schema, attr) do
      if attr.allow_nil? do
        %{schema | nullable: true}
      else
        schema
      end
    end

    defp with_attribute_nullability(schema, attr) do
      if schema["type"] == "any" || schema[:type] == :any do
        schema
      else
        if attr.allow_nil? do
          schema
          |> Map.put("nullable", true)
          |> Map.delete(:nullable)
        else
          schema
        end
      end
    end

    @spec resource_write_attribute_type(
            term(),
            resource :: Ash.Resource.t(),
            action_type :: atom,
            acc :: acc,
            format :: content_type_format()
          ) :: {Schema.t(), acc}
    @doc false
    def resource_write_attribute_type(attribute, resource, action_type, acc, format \\ :json)

    def resource_write_attribute_type(
          %Ash.Resource.Aggregate{type: nil} = agg,
          resource,
          action_type,
          acc,
          format
        ) do
      {type, constraints} = field_type(agg, resource)

      resource_write_attribute_type(
        Map.merge(agg, %{type: type, constraints: constraints}),
        resource,
        action_type,
        acc,
        format
      )
    end

    def resource_write_attribute_type(
          %{type: {:array, type}} = attr,
          resource,
          action_type,
          acc,
          format
        ) do
      {schema, acc} =
        resource_write_attribute_type(
          %{
            attr
            | type: type,
              constraints: attr.constraints[:items] || []
          },
          resource,
          action_type,
          acc,
          format
        )

      %Schema{
        type: :array,
        items: schema
      }
      |> with_attribute_description(attr)
      |> then(&{&1, acc})
    end

    def resource_write_attribute_type(
          %{type: Ash.Type.Map, constraints: constraints} = attr,
          resource,
          action_type,
          acc,
          format
        ) do
      if constraints[:fields] && constraints[:fields] != [] do
        {schema, acc} =
          Enum.reduce(constraints[:fields], {%{}, acc}, fn {key, config}, {fields, acc} ->
            {schema, acc} =
              resource_write_attribute_type(
                %{
                  attr
                  | type: config[:type],
                    constraints: config[:constraints] || []
                }
                |> Map.put(:description, config[:description] || nil),
                resource,
                action_type,
                acc,
                format
              )

            {Map.put(fields, key, schema), acc}
          end)

        %Schema{
          type: :object,
          additionalProperties: false,
          properties: schema,
          required:
            constraints[:fields]
            |> Enum.filter(fn {_, config} -> !config[:allow_nil?] end)
            |> Enum.map(&elem(&1, 0))
        }
        |> add_null_for_non_required()
        |> with_attribute_description(attr)
        |> then(&{&1, acc})
      else
        %Schema{type: :object}
        |> with_attribute_description(attr)
        |> then(&{&1, acc})
      end
    end

    def resource_write_attribute_type(
          %{type: Ash.Type.Union, constraints: constraints} = attr,
          resource,
          action_type,
          acc,
          format
        ) do
      {subtypes, acc} =
        Enum.reduce(constraints[:types], {[], acc}, fn {_name, config}, {list, acc} ->
          fake_attr =
            %{
              attr
              | type: Ash.Type.get_type(config[:type]),
                constraints: config[:constraints]
            }
            |> Map.put(:description, config[:description] || nil)

          {schema, acc} =
            resource_write_attribute_type(fake_attr, resource, action_type, acc, format)

          {[schema | list], acc}
        end)

      schema_with_description =
        %{
          "anyOf" => Enum.reverse(subtypes)
        }
        |> unwrap_any_of()
        |> with_attribute_description(attr)

      {schema_with_description, acc}
    end

    def resource_write_attribute_type(
          %{type: Ash.Type.Struct, constraints: constraints} = attr,
          resource,
          action_type,
          acc,
          format
        ) do
      if instance_of = constraints[:instance_of] do
        if AshJsonApi.JsonSchema.embedded?(instance_of) && !constraints[:fields] do
          embedded_type_input(attr, resource, action_type, acc, format)
        else
          {schema, acc} =
            resource_write_attribute_type(
              %{attr | type: Ash.Type.Map},
              resource,
              action_type,
              acc,
              format
            )

          {with_attribute_description(schema, attr), acc}
        end
      else
        {%Schema{}, acc}
      end
    end

    def resource_write_attribute_type(%{type: type} = attr, resource, action_type, acc, format) do
      {schema, acc} =
        cond do
          AshJsonApi.JsonSchema.embedded?(type) ->
            embedded_type_input(attr, resource, action_type, acc)

          :erlang.function_exported(type, :json_write_schema, 1) ->
            {type.json_write_schema(attr.constraints), acc}

          Ash.Type.NewType.new_type?(type) ->
            new_constraints = Ash.Type.NewType.constraints(type, attr.constraints)
            new_type = Ash.Type.NewType.subtype_of(type)

            resource_write_attribute_type(
              Map.merge(attr, %{type: Ash.Type.get_type(new_type), constraints: new_constraints}),
              resource,
              action_type,
              acc,
              format
            )

          true ->
            resource_attribute_type(attr, resource, acc, format)
        end

      {with_attribute_description(schema, attr), acc}
    end

    @spec resource_attribute_type(
            term(),
            resource :: Ash.Resource.t(),
            acc :: acc,
            format :: content_type_format()
          ) :: {Schema.t() | map(), acc}
    defp resource_attribute_type(type, resource, acc, format \\ :json)

    defp resource_attribute_type(%Ash.Resource.Aggregate{type: nil} = agg, resource, acc, format) do
      {type, constraints} = field_type(agg, resource)

      resource_attribute_type(
        Map.merge(agg, %{type: type, constraints: constraints}),
        resource,
        acc,
        format
      )
    end

    defp resource_attribute_type(%{type: Ash.Type.String}, _resource, acc, _format) do
      {%Schema{type: :string}, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.CiString}, _resource, acc, _format) do
      {%Schema{type: :string}, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.Boolean}, _resource, acc, _format) do
      {%Schema{type: :boolean}, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.Decimal}, _resource, acc, _format) do
      {%Schema{type: :string}, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.Integer}, _resource, acc, _format) do
      {%Schema{type: :integer}, acc}
    end

    defp resource_attribute_type(
           %{type: Ash.Type.Map, constraints: constraints} = attr,
           resource,
           acc,
           format
         ) do
      if constraints[:fields] && constraints[:fields] != [] do
        {properties, acc} =
          Enum.reduce(constraints[:fields], {%{}, acc}, fn {key, config}, {props, acc} ->
            {schema, acc} =
              resource_attribute_type(
                %{
                  attr
                  | type: Ash.Type.get_type(config[:type]),
                    constraints: config[:constraints] || []
                }
                |> Map.put(:description, config[:description] || nil),
                resource,
                acc,
                format
              )

            {Map.put(props, key, schema), acc}
          end)

        {%Schema{
           type: :object,
           properties: properties,
           additionalProperties: false,
           required:
             constraints[:fields]
             |> Enum.filter(fn {_, config} -> !config[:allow_nil?] end)
             |> Enum.map(&elem(&1, 0))
         }
         |> add_null_for_non_required(), acc}
      else
        {%Schema{type: :object}, acc}
      end
    end

    defp resource_attribute_type(%{type: Ash.Type.Float}, _resource, acc, _format) do
      {%Schema{type: :number, format: :float}, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.Date}, _resource, acc, _format) do
      {%Schema{
         type: :string,
         format: "date"
       }, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.UtcDatetime}, _resource, acc, _format) do
      {%Schema{
         type: :string,
         format: "date-time"
       }, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.NaiveDatetime}, _resource, acc, _format) do
      {%Schema{
         type: :string,
         format: "date-time"
       }, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.Time}, _resource, acc, _format) do
      {%Schema{
         type: :string,
         format: "time"
       }, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.UUID}, _resource, acc, _format) do
      {%Schema{
         type: :string,
         format: "uuid"
       }, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.UUIDv7}, _resource, acc, _format) do
      {%Schema{
         type: :string,
         format: "uuid"
       }, acc}
    end

    defp resource_attribute_type(
           %{type: Ash.Type.Atom, constraints: constraints},
           _resource,
           acc,
           _format
         ) do
      if one_of = constraints[:one_of] do
        {%Schema{
           type: :string,
           enum: Enum.map(one_of, &to_string/1)
         }, acc}
      else
        {%Schema{
           type: :string
         }, acc}
      end
    end

    defp resource_attribute_type(%{type: Ash.Type.DurationName}, _resource, acc, _format) do
      {%Schema{
         type: :string,
         enum: Enum.map(Ash.Type.DurationName.values(), &to_string/1)
       }, acc}
    end

    defp resource_attribute_type(%{type: Ash.Type.File}, _resource, acc, :json),
      do: {%Schema{type: :string, format: :byte, description: "Base64 encoded file content"}, acc}

    defp resource_attribute_type(%{type: Ash.Type.File}, _resource, acc, :multipart),
      do: {%Schema{type: :string, description: "Name of multipart upload file"}, acc}

    defp resource_attribute_type(
           %{type: Ash.Type.Union, constraints: constraints} = attr,
           resource,
           acc,
           format
         ) do
      {subtypes, acc} =
        Enum.reduce(constraints[:types], {[], acc}, fn {_name, config}, {types, acc} ->
          fake_attr =
            %{
              attr
              | type: Ash.Type.get_type(config[:type]),
                constraints: config[:constraints]
            }
            |> Map.put(:description, config[:description] || nil)

          {schema, acc} = resource_attribute_type(fake_attr, resource, acc, format)
          {[schema | types], acc}
        end)

      result =
        %{
          "anyOf" => Enum.reverse(subtypes)
        }
        |> unwrap_any_of()
        |> with_attribute_description(attr)

      {result, acc}
    end

    defp resource_attribute_type(%{type: {:array, type}} = attr, resource, acc, format) do
      {items_schema, acc} =
        resource_attribute_type(
          %{
            attr
            | type: type,
              constraints: attr.constraints[:items] || []
          },
          resource,
          acc,
          format
        )

      {%Schema{
         type: :array,
         items: items_schema
       }, acc}
    end

    defp resource_attribute_type(
           %{type: Ash.Type.Struct, constraints: constraints} = attr,
           resource,
           acc,
           format
         ) do
      if instance_of = constraints[:instance_of] do
        if AshJsonApi.JsonSchema.embedded?(instance_of) && !constraints[:fields] do
          # Check if this embedded resource has a JSON API type
          json_api_type = AshJsonApi.Resource.Info.type(instance_of)

          if json_api_type do
            # Check if schema already exists
            if Map.has_key?(acc.schemas, json_api_type) do
              # Use $ref to existing schema
              schema = %{"$ref" => "#/components/schemas/#{json_api_type}-type"}
              {schema, acc}
            else
              # Check if we've already seen this type/constraints combination
              type_key = {instance_of, nil, constraints}

              if type_key in acc.seen_non_schema_types do
                # We're in a recursive loop, return $ref
                # Recursive type detected, using $ref instead of inline definition
                schema = %{"$ref" => "#/components/schemas/#{json_api_type}-type"}
                {schema, acc}
              else
                # Build the schema and add it to schemas map
                new_acc = %{acc | seen_non_schema_types: [type_key | acc.seen_non_schema_types]}

                {properties, final_acc} =
                  resource_attributes(instance_of, nil, format, new_acc, false)

                resource_schema =
                  %Schema{
                    type: :object,
                    additionalProperties: false,
                    properties: properties,
                    required: required_attributes(instance_of)
                  }
                  |> add_null_for_non_required()

                # Add to schemas map
                final_acc = %{
                  final_acc
                  | schemas: Map.put(final_acc.schemas, "#{json_api_type}-type", resource_schema)
                }

                # Return $ref to the schema
                schema = %{"$ref" => "#/components/schemas/#{json_api_type}-type"}
                {schema, final_acc}
              end
            end
          else
            # No JSON API type, handle as before
            type_key = {instance_of, constraints}

            if type_key in acc.seen_non_schema_types do
              # We're in a recursive loop, return empty schema
              # Recursive type detected, returning empty schema to prevent infinite loop
              {%Schema{}, acc}
            else
              # Mark this type as seen and process it
              new_acc = %{acc | seen_non_schema_types: [type_key | acc.seen_non_schema_types]}

              {properties, final_acc} =
                resource_attributes(instance_of, nil, format, new_acc, false)

              schema =
                %Schema{
                  type: :object,
                  additionalProperties: false,
                  properties: properties,
                  required: required_attributes(instance_of)
                }
                |> add_null_for_non_required()

              {schema, final_acc}
            end
          end
        else
          resource_attribute_type(%{attr | type: Ash.Type.Map}, resource, acc, format)
        end
      else
        {%Schema{}, acc}
      end
    end

    defp resource_attribute_type(%{type: type} = attr, resource, acc, format) do
      constraints = attr.constraints

      cond do
        AshJsonApi.JsonSchema.embedded?(type) ->
          {properties, acc} = resource_attributes(type, nil, format, acc, false)

          %Schema{
            type: :object,
            additionalProperties: false,
            properties: properties,
            required: required_attributes(type)
          }
          |> add_null_for_non_required()
          |> then(&{&1, acc})

        function_exported?(type, :json_schema, 1) ->
          {type.json_schema(constraints), acc}

        Ash.Type.NewType.new_type?(type) ->
          new_constraints = Ash.Type.NewType.constraints(type, constraints)
          new_type = Ash.Type.NewType.subtype_of(type)

          resource_attribute_type(
            Map.merge(attr, %{type: Ash.Type.get_type(new_type), constraints: new_constraints}),
            resource,
            acc,
            format
          )

        Spark.implements_behaviour?(type, Ash.Type.Enum) ->
          {%Schema{
             type: :string,
             enum: Enum.map(type.values(), &to_string/1)
           }, acc}

        true ->
          {%Schema{}, acc}
      end
    end

    defp embedded_type_input(
           %{type: embedded_resource} = attribute,
           parent_resource,
           action_type,
           acc,
           format \\ :json
         ) do
      attribute = %{
        attribute
        | constraints: Ash.Type.NewType.constraints(embedded_resource, attribute.constraints)
      }

      embedded_resource =
        case attribute.constraints[:instance_of] do
          nil -> Ash.Type.NewType.subtype_of(embedded_resource)
          type -> type
        end

      input_schema_name =
        create_input_schema_name(attribute, parent_resource, action_type, embedded_resource)

      type_key = {embedded_resource, action_type, attribute.constraints}

      # Check for recursion
      if type_key in acc.seen_input_types do
        # We're in a recursive loop
        if input_schema_name do
          # Return $ref and unchanged accumulator (the schema will be created by the non-recursive path)
          schema = %{"$ref" => "#/components/schemas/#{input_schema_name}"}
          {schema, acc}
        else
          # No schema name, return empty schema to break recursion
          {%Schema{}, acc}
        end
      else
        # Not recursive, mark as seen and process normally
        new_acc = %{acc | seen_input_types: [type_key | acc.seen_input_types]}

        # Build the schema
        embedded_type_input_impl(
          attribute,
          embedded_resource,
          action_type,
          new_acc,
          format,
          input_schema_name
        )
      end
    end

    defp raise_action_names_in_schema_error(embedded_resource, action_name, action_type) do
      raise """
      The embedded resource #{inspect(embedded_resource)} has a non-primary #{action_type} action #{inspect(action_name)}.

      You need to configure the `action_names_in_schema` option in the embedded resource to disambiguate between action types.

      Example:

        json_api do
          action_names_in_schema [
            #{action_name}: "#{action_name}_#{action_type}"
          ]
        end
      """
    end

    defp create_input_schema_name(attribute, parent_resource, action_type, embedded_resource) do
      json_api_type = AshJsonApi.Resource.Info.type(embedded_resource)
      create_action = attribute.constraints[:create_action]
      update_action = attribute.constraints[:update_action]
      destroy_action = attribute.constraints[:destroy_action]

      action_identifier =
        cond do
          action_type == :create &&
              (create_action &&
                 create_action !=
                   Ash.Resource.Info.primary_action!(embedded_resource, :create).name) ->
            AshJsonApi.Resource.Info.action_names_in_schema(embedded_resource)[create_action] ||
              raise_action_names_in_schema_error(embedded_resource, create_action, :create)

          action_type == :update &&
              (update_action &&
                 update_action !=
                   Ash.Resource.Info.primary_action!(embedded_resource, :update).name) ->
            AshJsonApi.Resource.Info.action_names_in_schema(embedded_resource)[update_action] ||
              raise_action_names_in_schema_error(embedded_resource, update_action, :update)

          action_type == :destroy &&
              (destroy_action &&
                 destroy_action !=
                   Ash.Resource.Info.primary_action!(embedded_resource, :destroy).name) ->
            AshJsonApi.Resource.Info.action_names_in_schema(embedded_resource)[destroy_action] ||
              raise_action_names_in_schema_error(embedded_resource, destroy_action, :destroy)

          true ->
            to_string(action_type)
        end

      if json_api_type do
        "#{json_api_type}-input-#{action_identifier}-type"
      else
        # Use parent resource type and attribute name for schema naming
        # This matches the pattern used in the generated refs
        parent_type = AshJsonApi.Resource.Info.type(parent_resource)
        attribute_name = Map.get(attribute, :name)

        if parent_type && attribute_name do
          "#{parent_type}_#{attribute_name}-input-#{action_identifier}-type"
        else
          nil
        end
      end
    end

    defp embedded_type_input_impl(attribute, resource, action_type, acc, format, schema_name) do
      create_action =
        case attribute.constraints[:create_action] do
          nil ->
            Ash.Resource.Info.primary_action(resource, :create)

          name ->
            Ash.Resource.Info.action(resource, name)
        end

      update_action =
        case attribute.constraints[:update_action] do
          nil ->
            Ash.Resource.Info.primary_action(resource, :update)

          name ->
            Ash.Resource.Info.action(resource, name)
        end

      {create_write_attributes, acc} =
        if create_action do
          write_attributes(resource, create_action.arguments, create_action, nil, acc, format)
        else
          {%{}, acc}
        end

      {update_write_attributes, acc} =
        if update_action do
          write_attributes(resource, update_action.arguments, update_action, nil, acc, format)
        else
          {%{}, acc}
        end

      create_required_attributes =
        if create_action do
          required_write_attributes(resource, create_action.arguments, create_action)
        else
          []
        end

      update_required_attributes =
        if update_action do
          required_write_attributes(resource, update_action.arguments, update_action)
        else
          []
        end

      required =
        if action_type == :create do
          create_required_attributes
        else
          create_required_attributes
          |> MapSet.new()
          |> MapSet.intersection(MapSet.new(update_required_attributes))
          |> Enum.to_list()
        end

      schema =
        %Schema{
          type: :object,
          additionalProperties: false,
          properties:
            Map.merge(create_write_attributes, update_write_attributes, fn _k, l, r ->
              %{
                "anyOf" => [
                  l,
                  r
                ]
              }
              |> unwrap_any_of()
            end),
          required: required
        }
        |> add_null_for_non_required()

      if schema_name do
        # Store the schema in the accumulator
        final_acc = %{acc | schemas: Map.put(acc.schemas, schema_name, schema)}
        # Return a $ref to the schema
        ref_schema = %{"$ref" => "#/components/schemas/#{schema_name}"}
        {ref_schema, final_acc}
      else
        {schema, acc}
      end
    end

    defp unwrap_any_of(%{"anyOf" => options} = schema) do
      {options_remaining, options_to_add} =
        Enum.reduce(options, {[], []}, fn schema, {options, to_add} ->
          case schema do
            %{"anyOf" => _} = schema ->
              case unwrap_any_of(schema) do
                %{"anyOf" => nested_options} ->
                  {options, nested_options ++ to_add}

                schema ->
                  {options, [schema | to_add]}
              end

            _ ->
              {[schema | to_add], options}
          end
        end)

      case Enum.uniq(options_remaining ++ options_to_add) do
        [] ->
          %{"type" => "any"}

        [one] ->
          one

        many ->
          %{"anyOf" => many}
      end
      |> then(fn result ->
        case schema["description"] || schema[:description] do
          nil -> result
          description -> Map.put(result, "description", description)
        end
      end)
    end

    @spec with_attribute_description(
            Schema.t() | map(),
            Ash.Resource.Attribute.t() | Ash.Resource.Actions.Argument.t() | any
          ) :: Schema.t() | map()
    defp with_attribute_description(schema, %{description: nil}) do
      schema
    end

    defp with_attribute_description(schema, %{description: description}) do
      Map.merge(schema, %{description: description})
    end

    defp with_attribute_description(schema, %{"description" => description}) do
      Map.merge(schema, %{"description" => description})
    end

    defp with_attribute_description(schema, _) do
      schema
    end

    @spec relationships(resource :: Ash.Resource.t()) :: Schema.t()
    defp relationships(resource) do
      %Schema{
        description: "A relationships object for a #{AshJsonApi.Resource.Info.type(resource)}",
        type: :object,
        properties: resource_relationships(resource),
        additionalProperties: false
      }
    end

    @spec resource_relationships(resource :: module) :: %{atom => Schema.t()}
    defp resource_relationships(resource) do
      resource
      |> Ash.Resource.Info.public_relationships()
      |> Enum.filter(fn %{destination: destination} ->
        AshJsonApi.Resource.Info.type(destination)
      end)
      |> Map.new(fn rel ->
        data = resource_relationship_field_data(resource, rel)
        links = resource_relationship_link_data(resource, rel)

        object =
          if links do
            %Schema{properties: %{data: data, links: links}}
          else
            %Schema{properties: %{data: data}}
          end

        {rel.name, object}
      end)
    end

    defp resource_relationship_link_data(_resource, _rel) do
      nil
    end

    @spec resource_relationship_field_data(
            resource :: module,
            relationship :: Ash.Resource.Relationships.relationship()
          ) :: Schema.t()
    defp resource_relationship_field_data(_resource, %{
           cardinality: :many,
           name: name
         }) do
      %Schema{
        description: "Relationship data for #{name}",
        type: :array,
        items: %{
          description: "Resource identifiers for #{name}",
          type: :object,
          required: [:type, :id],
          additionalProperties: false,
          properties: %{
            type: %Schema{type: :string},
            id: %Schema{type: :string},
            meta: %Schema{
              type: :object,
              additionalProperties: true
            }
          }
        },
        uniqueItems: true
      }
    end

    defp resource_relationship_field_data(_resource, %{
           name: name
         }) do
      %Schema{
        description: "An identifier for #{name}",
        type: :object,
        nullable: true,
        required: [:type, :id],
        additionalProperties: false,
        properties: %{
          type: %Schema{type: :string},
          id: %Schema{type: :string},
          meta: %Schema{
            type: :object,
            additionalProperties: true
          }
        }
      }
    end

    @spec resource_write_relationship_field_data(
            resource :: module,
            Ash.Resource.Actions.Argument.t()
          ) :: Schema.t()
    defp resource_write_relationship_field_data(_resource, %{
           type: {:array, _},
           name: name
         }) do
      %Schema{
        description: "An array of inputs for #{name}",
        type: :array,
        items: %{
          description: "Resource identifiers for #{name}",
          type: :object,
          required: [:type, :id],
          additionalProperties: false,
          properties: %{
            type: %Schema{type: :string},
            id: %Schema{type: :string},
            meta: %Schema{
              type: :object,
              additionalProperties: true
            }
          }
        },
        uniqueItems: true
      }
    end

    defp resource_write_relationship_field_data(_resource, %{
           name: name
         }) do
      %Schema{
        description: "Identifiers for #{name}",
        type: :object,
        nullable: true,
        required: [:type, :id],
        additionalProperties: false,
        properties: %{
          type: %Schema{type: :string},
          id: %Schema{type: :string},
          meta: %Schema{
            type: :object,
            additionalProperties: true
          }
        }
      }
    end

    @doc """
    Tags based on resource names to include in the API spec
    """
    @spec tags(domain :: module | [module]) :: [Tag.t()]
    def tags(domains) when is_list(domains) do
      Enum.flat_map(domains, &tags(&1, domains))
    end

    def tags(domain, domains) do
      tag = AshJsonApi.Domain.Info.tag(domain)
      group_by = AshJsonApi.Domain.Info.group_by(domain)

      if tag && group_by == :domain do
        [
          %Tag{
            name: to_string(tag),
            description: "Operations on the #{tag} API."
          }
        ]
      else
        domain
        |> resources()
        |> Enum.reject(&AshJsonApi.Resource.Info.routes(&1, domains))
        |> Enum.map(fn resource ->
          name = AshJsonApi.Resource.Info.type(resource)

          %Tag{
            name: to_string(name),
            description: "Operations on the #{name} resource."
          }
        end)
      end
    end

    @doc """
    Paths (routes) from the domain.
    """
    @spec paths(domain :: module | [module], module | [module], opts :: Keyword.t(), acc :: acc()) ::
            {Paths.t(), acc()}
    def paths(domains, all_domains, opts, acc) when is_list(domains) do
      {all_paths, final_acc} =
        Enum.map_reduce(domains, acc, fn domain, acc ->
          paths(domain, all_domains, opts, acc)
        end)

      merged_paths = Enum.reduce(all_paths, %{}, &Map.merge/2)
      {merged_paths, final_acc}
    end

    def paths(domain, all_domains, opts, acc) do
      {paths_list, final_acc} =
        domain
        |> resources()
        |> Enum.flat_map_reduce(acc, fn resource, acc ->
          routes = AshJsonApi.Resource.Info.routes(resource, all_domains)

          {route_operations, acc} =
            Enum.map_reduce(routes, acc, fn route, acc ->
              route_operation(route, domain, resource, opts, acc)
            end)

          {route_operations, acc}
        end)

      final_paths =
        paths_list
        |> Enum.group_by(fn {path, _route_op} -> path end, fn {_path, route_op} -> route_op end)
        |> Map.new(fn {path, route_ops} -> {path, struct!(PathItem, route_ops)} end)

      {final_paths, final_acc}
    end

    @spec route_operation(
            Route.t(),
            domain :: module,
            resource :: module,
            opts :: Keyword.t(),
            acc :: acc()
          ) ::
            {{Paths.path(), {verb :: atom, Operation.t()}}, acc()}
    defp route_operation(route, domain, resource, opts, acc) do
      resource =
        if route.relationship &&
             route.type not in [
               :post_to_relationship,
               :patch_relationship,
               :delete_from_relationship
             ] do
          Ash.Resource.Info.related(resource, route.relationship)
        else
          resource
        end

      tag = AshJsonApi.Domain.Info.tag(domain)
      group_by = AshJsonApi.Domain.Info.group_by(domain)

      {path, path_params} = AshJsonApi.JsonSchema.route_href(route, domain, opts)
      {operation, acc} = operation(route, resource, path_params, acc)

      operation =
        if tag && group_by === :domain do
          Map.merge(operation, %{tags: [to_string(tag)]})
        else
          operation
        end

      {{path, {route.method, operation}}, acc}
    end

    @spec operation(Route.t(), resource :: module, path_params :: [String.t()], acc :: acc()) ::
            {Operation.t(), acc()}
    defp operation(route, resource, path_params, acc) do
      action =
        if route.type in [:post_to_relationship, :patch_relationship, :delete_from_relationship] do
          case route.action do
            nil -> Ash.Resource.Info.primary_action!(resource, :update)
            action -> Ash.Resource.Info.action(resource, action)
          end
        else
          Ash.Resource.Info.action(resource, route.action)
        end

      if route.type not in [:post_to_relationship, :patch_relationship, :delete_from_relationship] and
           !action do
        raise """
        No such action #{inspect(route.action)} for #{inspect(resource)}

        You likely have an incorrectly configured route.
        """
      end

      response_code =
        case route.method do
          :post -> 201
          _ -> 200
        end

      {parameters_list, acc} = parameters(route, resource, path_params, acc)
      {response, acc} = response_body(route, resource, acc)

      {request_body_result, request_schemas} = request_body(route, resource)

      acc_with_request_schemas = %{acc | schemas: Map.merge(acc.schemas, request_schemas)}

      operation = %Operation{
        description: action_description(action, route, resource),
        operationId: route.name,
        tags: [to_string(AshJsonApi.Resource.Info.type(resource))],
        parameters: parameters_list,
        responses: %{
          :default => %Reference{
            "$ref": "#/components/responses/errors"
          },
          response_code => response
        },
        requestBody: request_body_result
      }

      {operation, acc_with_request_schemas}
    end

    defp action_description(action, route, resource) do
      route.description || (action && action.description) || default_description(route, resource)
    end

    defp default_description(route, resource) do
      if route.name do
        "#{route.name} operation on #{AshJsonApi.Resource.Info.type(resource)} resource"
      else
        "#{route.route} operation on #{AshJsonApi.Resource.Info.type(resource)} resource"
      end
    end

    defp parameters(route, resource, route_params, acc) do
      # Handle different route types
      case route.type do
        type
        when type in [:post_to_relationship, :patch_relationship, :delete_from_relationship] ->
          {[], acc}

        :index ->
          static_params =
            Enum.filter(
              [
                filter_parameter(resource, route),
                sort_parameter(resource, route),
                page_parameter(Ash.Resource.Info.action(resource, route.action)),
                include_parameter(resource),
                fields_parameter(resource)
              ],
              & &1
            )

          {read_params, acc} = read_argument_parameters(route, resource, route_params, acc)

          all_params =
            static_params
            |> Enum.concat(read_params)
            |> Enum.map(fn param ->
              Map.update!(param, :name, &to_string/1)
            end)
            |> apply_route_params(route_params)

          {all_params, acc}

        type when type in [:get, :related] ->
          static_params =
            [include_parameter(resource), fields_parameter(resource)]
            |> Enum.filter(& &1)

          {read_params, acc} = read_argument_parameters(route, resource, route_params, acc)

          all_params =
            static_params
            |> Enum.concat(read_params)
            |> Enum.reverse()
            |> Enum.map(fn param ->
              Map.update!(param, :name, &to_string/1)
            end)
            |> Enum.uniq_by(& &1.name)
            |> Enum.reverse()
            |> apply_route_params(route_params)

          {all_params, acc}

        _ ->
          # Default behavior for other route types
          action = Ash.Resource.Info.action(resource, route.action)

          {query_params, _unused_schema} =
            if route.method == :get do
              Enum.flat_map(Ash.Resource.Info.action_inputs(resource, route.action), fn input ->
                with true <- is_atom(input),
                     arg when not is_nil(arg) <-
                       Enum.find(action.arguments, &(&1.name == input)) ||
                         Ash.Resource.Info.attribute(resource, input) do
                  [arg]
                else
                  _ ->
                    []
                end
              end)
            else
              Enum.map(route.query_params, fn name ->
                argument = Enum.find(action.arguments, &(&1.name == name))

                if argument do
                  argument
                else
                  if name in Map.get(action, :accept, []) do
                    Ash.Resource.Info.attribute(resource, name)
                  else
                    nil
                  end
                end
              end)
            end
            |> Enum.concat(
              Enum.map(route_params, fn route_param ->
                case Enum.find(action.arguments, &(to_string(&1.name) == route_param)) do
                  nil ->
                    if Enum.any?(Map.get(action, :accept, []), &(to_string(&1) == route_param)) do
                      Ash.Resource.Info.attribute(resource, route_param)
                    else
                      nil
                    end

                  argument ->
                    argument
                end
              end)
            )
            |> Enum.filter(& &1)
            |> Enum.reduce({[], acc}, fn argument_or_attribute, {list, acc} ->
              {schema, acc} =
                resource_write_attribute_type(argument_or_attribute, resource, action.type, acc)

              location =
                if to_string(argument_or_attribute.name) in route_params do
                  :path
                else
                  :query
                end

              style =
                if (Map.get(schema, :type) == :object || Map.has_key?(schema, "$ref")) &&
                     location == :query do
                  :deepObject
                else
                  :form
                end

              {[
                 %Parameter{
                   name: to_string(argument_or_attribute.name),
                   in: location,
                   description: argument_or_attribute.description,
                   required: location == :path || !argument_or_attribute.allow_nil?,
                   style: style,
                   schema: schema
                 }
                 | list
               ], acc}
            end)

          static_params =
            if route.type == :route do
              []
            else
              [include_parameter(resource), fields_parameter(resource)]
            end
            |> Enum.filter(& &1)

          all_params =
            static_params
            |> Enum.concat(Enum.reverse(query_params))
            |> Enum.reverse()
            |> Enum.map(fn param ->
              Map.update!(param, :name, &to_string/1)
            end)
            |> Enum.uniq_by(& &1.name)
            |> Enum.reverse()
            |> apply_route_params(route_params)

          {all_params, acc}
      end
    end

    defp apply_route_params(params, route_params) do
      param_names = Enum.map(params, & &1.name)

      route_params_to_add =
        route_params
        |> Enum.reject(&(&1 in param_names))
        |> Enum.map(fn name ->
          %Parameter{
            name: name,
            in: :path,
            required: true,
            style: :form,
            schema: %Schema{type: :string}
          }
        end)

      route_params_to_add ++ Enum.sort_by(params, &(&1.in == :query))
    end

    @spec filter_parameter(resource :: module, route :: AshJsonApi.Resource.Route.t()) ::
            Parameter.t()
    defp filter_parameter(resource, route) do
      if route.derive_filter? && read_action?(resource, route) &&
           AshJsonApi.Resource.Info.derive_filter?(resource) do
        %Parameter{
          name: :filter,
          in: :query,
          description:
            "Filters the query to results with attributes matching the given filter object",
          required: false,
          style: :deepObject,
          schema: %Reference{
            "$ref": "#/components/schemas/#{AshJsonApi.Resource.Info.type(resource)}-filter"
          }
        }
      end
    end

    @spec sort_parameter(resource :: module, route :: AshJsonApi.Resource.Route.t()) ::
            Parameter.t()
    defp sort_parameter(resource, route) do
      if route.derive_sort? && read_action?(resource, route) &&
           AshJsonApi.Resource.Info.derive_sort?(resource) do
        sorts =
          resource
          |> AshJsonApi.JsonSchema.sortable_fields()
          |> Enum.flat_map(fn attr ->
            name = to_string(attr.name)
            [name, "-" <> name, "\\+\\+" <> name, "--" <> name]
          end)

        %Parameter{
          name: :sort,
          in: :query,
          description: "Sort order to apply to the results",
          required: false,
          style: :form,
          explode: false,
          schema: %Schema{
            type: :string,
            pattern: csv_regex(sorts)
          }
        }
      end
    end

    @spec page_parameter(term()) :: Parameter.t()
    defp page_parameter(action) do
      if action.type == :read && action.pagination &&
           (action.pagination.keyset? || action.pagination.offset?) do
        cond do
          action.pagination.keyset? && action.pagination.offset? ->
            keyset_pagination_schema = keyset_pagination_schema(action.pagination)
            offset_pagination_schema = offset_pagination_schema(action.pagination)
            keyset_props = keyset_pagination_schema.properties
            offset_props = offset_pagination_schema.properties

            schema_props =
              Map.merge(keyset_props, offset_props, fn _key, keyset_prop, offset_prop ->
                if keyset_prop == offset_prop do
                  keyset_prop
                else
                  %Schema{
                    anyOf: [
                      keyset_prop,
                      offset_prop
                    ]
                  }
                end
              end)

            %Parameter{
              name: :page,
              in: :query,
              description:
                "Paginates the response with the limit and offset or keyset pagination.",
              required: action.pagination.required? && !action.pagination.default_limit,
              style: :deepObject,
              schema: %Schema{
                type: :object,
                properties: schema_props,
                example: keyset_pagination_schema.example
              }
            }

          action.pagination.keyset? ->
            %Parameter{
              name: :page,
              in: :query,
              description:
                "Paginates the response with the limit and offset or keyset pagination.",
              required: action.pagination.required? && !action.pagination.default_limit,
              style: :deepObject,
              schema: keyset_pagination_schema(action.pagination)
            }

          action.pagination.offset? ->
            %Parameter{
              name: :page,
              in: :query,
              description:
                "Paginates the response with the limit and offset or keyset pagination.",
              required: action.pagination.required? && !action.pagination.default_limit,
              style: :deepObject,
              schema: offset_pagination_schema(action.pagination)
            }
        end
      end
    end

    defp offset_pagination_schema(pagination) do
      %Schema{
        type: :object,
        example: %{
          limit: pagination.default_limit || 25,
          offset: 0
        },
        properties:
          %{
            limit: %Schema{type: :integer, minimum: 1},
            offset: %Schema{type: :integer, minimum: 0}
          }
          |> add_count(pagination)
      }
    end

    defp keyset_pagination_schema(pagination) do
      %Schema{
        type: :object,
        example: %{
          limit: pagination.default_limit || 25
        },
        properties:
          %{
            limit: %Schema{type: :integer, minimum: 1},
            after: %Schema{type: :string},
            before: %Schema{type: :string}
          }
          |> add_count(pagination)
      }
    end

    defp add_count(props, pagination) do
      if pagination.countable do
        Map.put(props, :count, %Schema{
          type: :boolean,
          default: pagination.countable == :by_default
        })
      else
        props
      end
    end

    @spec include_parameter(resource :: Ash.Resource.t()) :: Parameter.t()
    defp include_parameter(resource) do
      all_includes =
        resource
        |> AshJsonApi.Resource.Info.includes()
        |> all_paths()
        |> Enum.map(&Enum.join(&1, "."))

      %Parameter{
        name: :include,
        in: :query,
        required: false,
        description: "Relationship paths to include in the response",
        style: :form,
        explode: false,
        schema: %Schema{
          type: :string,
          pattern: csv_regex(all_includes)
        }
      }
    end

    @spec fields_parameter(resource :: module) :: Parameter.t()
    defp fields_parameter(resource) do
      type = AshJsonApi.Resource.Info.type(resource)

      example =
        Enum.join(
          AshJsonApi.Resource.Info.default_fields(resource) ||
            Enum.map(Ash.Resource.Info.public_attributes(resource), & &1.name),
          ","
        )

      %Parameter{
        name: :fields,
        in: :query,
        description: "Limits the response fields to only those listed for each type",
        required: false,
        style: :deepObject,
        schema: %Schema{
          type: :object,
          additionalProperties: true,
          example: %{
            type => example
          },
          properties: %{
            type => %Schema{
              description: "Comma separated field names for #{type}",
              type: :string,
              example: example
            }
          }
        }
      }
    end

    @spec read_argument_parameters(
            Route.t(),
            resource :: module,
            route_params :: [String.t()],
            acc :: acc
          ) ::
            {[Parameter.t()], acc}
    defp read_argument_parameters(route, resource, route_params, acc) do
      action = Ash.Resource.Info.action(resource, route.action)

      action.arguments
      |> Enum.filter(& &1.public?)
      |> Enum.reduce({[], acc}, fn argument, {params, acc} ->
        {schema, acc} = resource_attribute_type(argument, resource, acc)

        location =
          if to_string(argument.name) in route_params do
            :path
          else
            :query
          end

        style =
          if schema.type == :object && location == :query do
            :deepObject
          else
            :form
          end

        param = %Parameter{
          name: argument.name,
          in: location,
          description: argument.description,
          required: location == :path || !argument.allow_nil?,
          style: style,
          schema: schema
        }

        {[param | params], acc}
      end)
      |> then(fn {params, acc} -> {Enum.reverse(params), acc} end)
    end

    @spec request_body(Route.t(), resource :: module) :: {nil | RequestBody.t(), map()}
    defp request_body(%{type: type}, _resource)
         when type not in [
                :route,
                :post,
                :patch,
                :post_to_relationship,
                :patch_relationship,
                :delete_from_relationship
              ] do
      {nil, %{}}
    end

    defp request_body(route, resource) do
      {json_body_schema, json_acc} = request_body_schema(route, resource, :json, empty_acc())

      {multipart_body_schema, multipart_acc} =
        request_body_schema(route, resource, :multipart, empty_acc())

      all_schemas = Map.merge(json_acc.schemas, multipart_acc.schemas)

      body =
        if route.type == :route &&
             (route.method in [:delete, :get] ||
                Enum.empty?(json_body_schema.properties.data.properties)) do
          nil
        else
          body_required =
            cond do
              route.type in [
                :post_to_relationship,
                :delete_from_relationship,
                :patch_relationship
              ] ->
                true

              route.type == :route ->
                json_body_schema.properties.data.required != []

              true ->
                json_body_schema.properties.data.properties.attributes.required != [] ||
                  json_body_schema.properties.data.properties.relationships.required != []
            end

          content =
            if json_body_schema == multipart_body_schema do
              # No file inputs declared, multipart is not necessary
              %{
                "application/vnd.api+json" => %MediaType{schema: json_body_schema}
              }
            else
              %{
                "application/vnd.api+json" => %MediaType{schema: json_body_schema},
                "multipart/x.ash+form-data" => %MediaType{
                  schema: %Schema{
                    multipart_body_schema
                    | additionalProperties: %{type: :string, format: :binary}
                  }
                }
              }
            end

          %RequestBody{
            description:
              "Request body for the #{route.name || route.route} operation on #{AshJsonApi.Resource.Info.type(resource)} resource",
            required: body_required,
            content: content
          }
        end

      {body, all_schemas}
    end

    @spec request_body_schema(
            Route.t(),
            resource :: module,
            format :: content_type_format(),
            acc :: acc
          ) ::
            {Schema.t(), acc}
    defp request_body_schema(
           %{
             type: :route,
             action: action
           } = route,
           resource,
           format,
           acc
         ) do
      action = Ash.Resource.Info.action(resource, action)

      {properties, acc} =
        write_attributes(
          resource,
          action.arguments,
          action,
          route,
          acc,
          format
        )

      schema = %Schema{
        type: :object,
        required: [:data],
        additionalProperties: false,
        properties: %{
          data:
            %Schema{
              type: :object,
              additionalProperties: false,
              properties: properties,
              required: required_write_attributes(resource, action.arguments, action, route)
            }
            |> add_null_for_non_required()
        }
      }

      {schema, acc}
    end

    defp request_body_schema(
           %{
             type: :post,
             action: action,
             relationship_arguments: relationship_arguments
           } = route,
           resource,
           format,
           acc
         ) do
      action = Ash.Resource.Info.action(resource, action)

      non_relationship_arguments =
        Enum.reject(
          action.arguments,
          &has_relationship_argument?(relationship_arguments, &1.name)
        )

      {properties, acc} =
        write_attributes(
          resource,
          non_relationship_arguments,
          action,
          route,
          acc,
          format
        )

      schema = %Schema{
        type: :object,
        required: [:data],
        additionalProperties: false,
        properties: %{
          data: %Schema{
            type: :object,
            additionalProperties: false,
            properties: %{
              type: %Schema{
                enum: [AshJsonApi.Resource.Info.type(resource)]
              },
              attributes:
                %Schema{
                  type: :object,
                  additionalProperties: false,
                  properties: properties,
                  required:
                    required_write_attributes(resource, non_relationship_arguments, action, route)
                }
                |> add_null_for_non_required(),
              relationships: %Schema{
                type: :object,
                additionalProperties: false,
                properties: write_relationships(resource, relationship_arguments, action),
                required:
                  required_relationship_attributes(resource, relationship_arguments, action)
              }
            }
          }
        }
      }

      {schema, acc}
    end

    defp request_body_schema(
           %{
             type: :patch,
             action: action,
             relationship_arguments: relationship_arguments
           } = route,
           resource,
           format,
           acc
         ) do
      action = Ash.Resource.Info.action(resource, action)

      non_relationship_arguments =
        Enum.reject(
          action.arguments,
          &has_relationship_argument?(relationship_arguments, &1.name)
        )

      {properties, acc} =
        write_attributes(
          resource,
          non_relationship_arguments,
          action,
          route,
          acc,
          format
        )

      schema = %Schema{
        type: :object,
        required: [:data],
        additionalProperties: false,
        properties: %{
          data: %Schema{
            type: :object,
            additionalProperties: false,
            required: [:id],
            properties: %{
              id: %Schema{
                type: :string
              },
              type: %Schema{
                enum: [AshJsonApi.Resource.Info.type(resource)]
              },
              attributes:
                %Schema{
                  type: :object,
                  additionalProperties: false,
                  properties: properties,
                  required:
                    required_write_attributes(resource, non_relationship_arguments, action, route)
                }
                |> add_null_for_non_required(),
              relationships: %Schema{
                type: :object,
                additionalProperties: false,
                properties: write_relationships(resource, relationship_arguments, action),
                required:
                  required_relationship_attributes(resource, relationship_arguments, action)
              }
            }
          }
        }
      }

      {schema, acc}
    end

    defp request_body_schema(
           %{type: type, relationship: relationship},
           resource,
           _format,
           acc
         )
         when type in [:post_to_relationship, :patch_relationship, :delete_from_relationship] do
      schema =
        resource
        |> Ash.Resource.Info.public_relationship(relationship)
        |> relationship_resource_identifiers()

      {schema, acc}
    end

    @doc false
    def required_write_attributes(resource, arguments, action, route \\ nil) do
      arguments =
        arguments
        |> Enum.filter(& &1.public?)
        |> without_path_arguments(route)
        |> without_query_params(route)

      attributes =
        case action.type do
          type when type in [:action, :read] ->
            []

          :update ->
            action.require_attributes

          _ ->
            resource
            |> Ash.Resource.Info.attributes()
            |> Enum.filter(&(&1.name in action.accept && &1.writable?))
            |> Enum.reject(
              &(&1.name in arguments || &1.allow_nil? || not is_nil(&1.default) || &1.generated? ||
                  &1.name in Map.get(action, :allow_nil_input, []))
            )
            |> Enum.map(& &1.name)
        end

      arguments =
        arguments
        |> Enum.reject(& &1.allow_nil?)
        |> Enum.map(& &1.name)

      Enum.uniq(attributes ++ arguments ++ Map.get(action, :require_attributes, []))
    end

    @spec write_attributes(
            resource :: module,
            [Ash.Resource.Actions.Argument.t()],
            action :: term(),
            route :: term(),
            acc :: acc,
            format :: content_type_format()
          ) :: {%{atom => Schema.t()}, acc}
    def write_attributes(resource, arguments, action, route, acc, format) do
      {attributes, acc} =
        if action.type in [:action, :read] do
          {%{}, acc}
        else
          resource
          |> Ash.Resource.Info.attributes()
          |> Enum.filter(&(&1.name in action.accept && &1.writable?))
          |> Enum.reduce({%{}, acc}, fn attribute, {attrs, acc} ->
            {schema, acc} =
              resource_write_attribute_type(attribute, resource, action.type, acc, format)

            {Map.put(attrs, attribute.name, schema), acc}
          end)
        end

      arguments
      |> Enum.filter(& &1.public?)
      |> without_path_arguments(route)
      |> without_query_params(route)
      |> Enum.reduce({attributes, acc}, fn argument, {attributes, acc} ->
        {schema, acc} = resource_write_attribute_type(argument, resource, :create, acc, format)
        {Map.put(attributes, argument.name, schema), acc}
      end)
    end

    defp without_path_arguments(arguments, %{route: route}) do
      route_params =
        route
        |> Path.split()
        |> Enum.filter(&String.starts_with?(&1, ":"))
        |> Enum.map(&String.trim_leading(&1, ":"))

      Enum.reject(arguments, fn argument ->
        to_string(argument.name) in route_params
      end)
    end

    defp without_path_arguments(arguments, _), do: arguments

    defp without_query_params(inputs, %{query_params: query_params}) do
      query_params = List.wrap(query_params)
      Enum.reject(inputs, &(&1.name in query_params))
    end

    defp without_query_params(inputs, _), do: inputs

    @spec required_relationship_attributes(
            resource :: module,
            [Actions.Argument.t()],
            Actions.action()
          ) :: [atom()]
    defp required_relationship_attributes(_resource, relationship_arguments, action) do
      action.arguments
      |> Enum.filter(&has_relationship_argument?(relationship_arguments, &1.name))
      |> Enum.reject(& &1.allow_nil?)
      |> Enum.map(& &1.name)
    end

    @spec write_relationships(resource :: module, [Actions.Argument.t()], Actions.action()) ::
            %{atom() => Schema.t()}
    defp write_relationships(resource, relationship_arguments, action) do
      action.arguments
      |> Enum.filter(&has_relationship_argument?(relationship_arguments, &1.name))
      |> Map.new(fn argument ->
        data = resource_write_relationship_field_data(resource, argument)

        schema = %Schema{
          type: :object,
          additionalProperties: false,
          properties: %{
            data: data,
            links: %Schema{type: :object, additionalProperties: true}
          }
        }

        {argument.name, schema}
      end)
    end

    @spec has_relationship_argument?(relationship_arguments :: list, name :: atom) :: boolean()
    defp has_relationship_argument?(relationship_arguments, name) do
      Enum.any?(relationship_arguments, fn
        {:id, ^name} -> true
        ^name -> true
        _ -> false
      end)
    end

    @spec response_body(Route.t(), resource :: module, acc :: acc()) :: {Response.t(), acc()}
    defp response_body(%{type: :delete}, _resource, acc) do
      response = %Response{
        description: "Deleted successfully"
      }

      {response, acc}
    end

    defp response_body(route, resource, acc) do
      {schema, acc} = response_schema(route, resource, acc)

      response = %Response{
        description: "Success",
        content: %{
          "application/vnd.api+json" => %MediaType{
            schema: schema
          }
        }
      }

      {response, acc}
    end

    @spec response_schema(Route.t(), resource :: module, acc :: acc()) :: {Schema.t(), acc()}
    defp response_schema(route, resource, acc) do
      case route.type do
        :route ->
          action = Ash.Resource.Info.action(resource, route.action)

          if action.returns do
            {return_type, acc} =
              resource_attribute_type(
                %{type: action.returns, constraints: action.constraints},
                resource,
                acc
              )

            schema =
              if route.wrap_in_result? do
                %Schema{
                  type: :object,
                  additionalProperties: false,
                  properties: %{
                    result: return_type
                  },
                  required: [:result]
                }
              else
                return_type
              end

            {schema, acc}
          else
            schema = %Schema{
              type: :object,
              additionalProperties: false,
              properties: %{
                success: %Schema{enum: [true]}
              },
              required: [:success]
            }

            {schema, acc}
          end

        :index ->
          schema = %Schema{
            type: :object,
            additionalProperties: false,
            properties: %{
              data: %Schema{
                description:
                  "An array of resource objects representing a #{AshJsonApi.Resource.Info.type(resource)}",
                type: :array,
                items: item_reference(route, resource),
                uniqueItems: true
              },
              included: included_resource_schemas(resource),
              meta: %Schema{
                type: :object,
                additionalProperties: true
              }
            }
          }

          {schema, acc}

        :delete ->
          {nil, acc}

        type
        when type in [:post_to_relationship, :patch_relationship, :delete_from_relationship] ->
          resource
          |> Ash.Resource.Info.public_relationship(route.relationship)
          |> tap(fn
            nil ->
              if Ash.Resource.Info.relationship(resource, route.relationship) do
                raise "Relationship #{route.relationship} on #{inspect(resource)} must be public to use in a route"
              else
                raise "No such relationship #{route.relationship} for #{inspect(resource)}"
              end

            relationship ->
              relationship
          end)
          |> relationship_resource_identifiers()
          |> then(&{&1, acc})

        _ ->
          schema = %Schema{
            additionalProperties: false,
            properties: %{
              data: item_reference(route, resource),
              included: included_resource_schemas(resource),
              meta: %Schema{
                type: :object,
                additionalProperties: true
              }
            }
          }

          {schema, acc}
      end
    end

    defp item_reference(%{default_fields: nil}, resource) do
      %Reference{
        "$ref": "#/components/schemas/#{AshJsonApi.Resource.Info.type(resource)}"
      }
    end

    defp item_reference(%{default_fields: default_fields}, resource) do
      {schema, _acc} = resource_object_schema(resource, default_fields, %{})
      schema
    end

    @spec relationship_resource_identifiers(relationship :: Relationships.relationship()) ::
            Schema.t()
    defp relationship_resource_identifiers(relationship) do
      %Schema{
        type: :object,
        required: [:data],
        additionalProperties: false,
        properties: %{
          data: %{
            type: :array,
            items: %{
              type: :object,
              required: [:id, :type],
              additionalProperties: false,
              properties: %{
                id: %Schema{
                  type: :string
                },
                type: %Schema{
                  enum: [AshJsonApi.Resource.Info.type(relationship.destination)]
                },
                meta: %Schema{
                  type: :object,
                  additionalProperties: true
                }
              }
            }
          }
        }
      }
    end

    defp included_resource_schemas(resource) do
      includes = AshJsonApi.Resource.Info.includes(resource)
      include_resources = includes_to_resources(resource, includes)

      include_schemas =
        include_resources
        |> Enum.filter(&AshJsonApi.Resource.Info.type(&1))
        |> Enum.map(fn resource ->
          %Reference{"$ref": "#/components/schemas/#{AshJsonApi.Resource.Info.type(resource)}"}
        end)

      %Schema{
        type: :array,
        uniqueItems: true,
        items: %Schema{
          oneOf: include_schemas
        }
      }
    end

    defp includes_to_resources(nil, _), do: []

    defp includes_to_resources(resource, includes) when is_list(includes) do
      includes
      |> Enum.flat_map(fn
        {include, []} ->
          relationship_destination(resource, include) |> List.wrap()

        {include, includes} ->
          case relationship_destination(resource, include) do
            nil ->
              []

            resource ->
              [resource | includes_to_resources(resource, includes)]
          end

        include ->
          relationship_destination(resource, include) |> List.wrap()
      end)
      |> Enum.uniq()
    end

    defp includes_to_resources(resource, include),
      do: relationship_destination(resource, include) |> List.wrap()

    defp relationship_destination(resource, include) do
      resource
      |> Ash.Resource.Info.public_relationship(include)
      |> case do
        %{destination: destination} -> destination
        _ -> nil
      end
    end

    defp filter_field_types(resource, acc) do
      {attr_types, acc} = filter_attribute_types(resource, acc)
      {agg_types, acc} = filter_aggregate_types(resource, acc)
      {calc_types, acc} = filter_calculation_types(resource, acc)
      {attr_types ++ agg_types ++ calc_types, acc}
    end

    defp filter_attribute_types(resource, acc) do
      resource
      |> Ash.Resource.Info.public_attributes()
      |> Enum.filter(&filterable?(&1, resource))
      |> Enum.reduce({[], acc}, fn attribute, {results, acc} ->
        {result, acc} = filter_type(attribute, resource, acc)
        {results ++ result, acc}
      end)
    end

    defp filter_aggregate_types(resource, acc) do
      resource
      |> Ash.Resource.Info.public_aggregates()
      |> Enum.filter(&filterable?(&1, resource))
      |> Enum.reduce({[], acc}, fn aggregate, {results, acc} ->
        {result, acc} = filter_type(aggregate, resource, acc)
        {results ++ result, acc}
      end)
    end

    defp filter_calculation_types(resource, acc) do
      resource
      |> Ash.Resource.Info.public_calculations()
      |> Enum.filter(&filterable?(&1, resource))
      |> Enum.reduce({[], acc}, fn calculation, {results, acc} ->
        {result, acc} = filter_type(calculation, resource, acc)
        {results ++ result, acc}
      end)
    end

    defp field_type(%Ash.Resource.Attribute{type: type, constraints: constraints}, _resource),
      do: {type, constraints}

    defp field_type(%Ash.Resource.Calculation{type: type, constraints: constraints}, _resource),
      do: {type, constraints}

    defp field_type(%Ash.Resource.Aggregate{type: type, constraints: constraints}, _resource)
         when not is_nil(type),
         do: {type, constraints}

    defp field_type(
           %Ash.Resource.Aggregate{
             kind: kind,
             field: field,
             relationship_path: relationship_path
           },
           resource
         ) do
      field_type =
        with field when not is_nil(field) <- field,
             related when not is_nil(related) <-
               Ash.Resource.Info.related(resource, relationship_path),
             attr when not is_nil(attr) <- Ash.Resource.Info.field(related, field) do
          attr.type
        end

      {:ok, aggregate_type, constraints} =
        Ash.Query.Aggregate.kind_to_type(kind, field_type, [])

      {aggregate_type, constraints}
    end

    @doc false
    def filter_type(field, resource, acc) do
      {result, acc} = raw_filter_type(field, resource, acc)

      case result do
        nil ->
          {[], acc}

        schema ->
          {[{attribute_filter_field_type(resource, field), schema}], acc}
      end
    end

    def raw_filter_type(%Ash.Resource.Calculation{} = calculation, resource, acc) do
      {type, _constraints} = field_type(calculation, resource)

      {input, acc} =
        if Enum.empty?(calculation.arguments) do
          {[], acc}
        else
          {inputs, acc} =
            Enum.reduce(calculation.arguments, {[], acc}, fn argument, {inputs, acc} ->
              {schema, acc} = resource_write_attribute_type(argument, resource, :create, acc)
              {[{argument.name, schema} | inputs], acc}
            end)

          inputs = Enum.reverse(inputs)

          required =
            Enum.flat_map(calculation.arguments, fn argument ->
              if argument.allow_nil? do
                []
              else
                [argument.name]
              end
            end)

          {[
             {:input,
              %Schema{
                type: :object,
                properties: Map.new(inputs),
                required: required,
                additionalProperties: false
              }}
           ], acc}
        end

      array_type? = match?({:array, _}, type)

      {fields, acc} =
        Ash.Filter.builtin_operators()
        |> Enum.concat(Ash.Filter.builtin_functions())
        |> Enum.concat(Ash.DataLayer.functions(resource))
        |> Enum.filter(& &1.predicate?())
        |> restrict_for_lists(type)
        |> Enum.reduce({[], acc}, fn operator, {fields, acc} ->
          {operator_fields, acc} =
            filter_fields(operator, type, array_type?, calculation, resource, acc)

          {fields ++ operator_fields, acc}
        end)

      input_required = Enum.any?(calculation.arguments, &(!&1.allow_nil?))

      fields_with_input =
        Enum.concat(fields, input)

      required =
        if input_required do
          [:input]
        else
          []
        end

      if fields == [] do
        {nil, acc}
      else
        {%Schema{
           type: :object,
           required: required,
           properties: Map.new(fields_with_input),
           additionalProperties: false
         }
         |> with_attribute_description(calculation), acc}
      end
    end

    def raw_filter_type(attribute_or_aggregate, resource, acc) do
      {type, _constraints} = field_type(attribute_or_aggregate, resource)
      array_type? = match?({:array, _}, type)

      {fields, acc} =
        Ash.Filter.builtin_operators()
        |> Enum.concat(Ash.Filter.builtin_functions())
        |> Enum.concat(Ash.DataLayer.functions(resource))
        |> Enum.filter(& &1.predicate?())
        |> restrict_for_lists(type)
        |> Enum.reduce({[], acc}, fn operator, {fields, acc} ->
          {operator_fields, acc} =
            filter_fields(operator, type, array_type?, attribute_or_aggregate, resource, acc)

          {fields ++ operator_fields, acc}
        end)

      if fields == [] do
        {nil, acc}
      else
        {%Schema{
           type: :object,
           properties: Map.new(fields),
           additionalProperties: false
         }
         |> with_attribute_description(attribute_or_aggregate), acc}
      end
    end

    defp attribute_filter_field_type(resource, attribute) do
      AshJsonApi.Resource.Info.type(resource) <> "-filter-" <> to_string(attribute.name)
    end

    defp resource_filter_fields(resource, domains) do
      Enum.concat([
        boolean_filter_fields(resource),
        attribute_filter_fields(resource),
        relationship_filter_fields(resource, domains),
        aggregate_filter_fields(resource),
        calculation_filter_fields(resource)
      ])
      |> Map.new()
    end

    def resource_filter_fields_fields_only(resource) do
      Enum.concat([
        attribute_filter_fields(resource),
        aggregate_filter_fields(resource),
        calculation_filter_fields(resource)
      ])
      |> Map.new()
    end

    defp relationship_filter_fields(resource, domains) do
      all_resources =
        domains
        |> Enum.flat_map(&Ash.Domain.Info.resources/1)
        |> Enum.filter(&AshJsonApi.Resource.Info.type/1)

      resource
      |> Ash.Resource.Info.public_relationships()
      |> Enum.filter(
        &(&1.destination in all_resources &&
            AshJsonApi.Resource.Info.derive_filter?(&1.destination) &&
            AshJsonApi.Resource in Spark.extensions(&1.destination) &&
            AshJsonApi.Resource.Info.type(&1.destination))
      )
      |> Enum.map(fn relationship ->
        {relationship.name,
         %Reference{
           "$ref":
             "#/components/schemas/#{AshJsonApi.Resource.Info.type(relationship.destination)}-filter"
         }}
      end)
    end

    defp calculation_filter_fields(resource) do
      if Ash.DataLayer.data_layer_can?(resource, :expression_calculation) do
        resource
        |> Ash.Resource.Info.public_calculations()
        |> Enum.filter(&filterable?(&1, resource))
        |> Enum.map(fn calculation ->
          {calculation.name,
           %Reference{
             "$ref": "#/components/schemas/#{attribute_filter_field_type(resource, calculation)}"
           }}
        end)
      else
        []
      end
    end

    defp attribute_filter_fields(resource) do
      resource
      |> Ash.Resource.Info.public_attributes()
      |> Enum.filter(&filterable?(&1, resource))
      |> Enum.map(fn attribute ->
        {attribute.name,
         %Reference{
           "$ref": "#/components/schemas/#{attribute_filter_field_type(resource, attribute)}"
         }}
      end)
    end

    defp aggregate_filter_fields(resource) do
      if Ash.DataLayer.data_layer_can?(resource, :aggregate_filter) do
        resource
        |> Ash.Resource.Info.public_aggregates()
        |> Enum.filter(&filterable?(&1, resource))
        |> Enum.map(fn aggregate ->
          {aggregate.name,
           %Reference{
             "$ref": "#/components/schemas/#{attribute_filter_field_type(resource, aggregate)}"
           }}
        end)
      else
        []
      end
    end

    defp boolean_filter_fields(resource) do
      if Ash.DataLayer.can?(:boolean_filter, resource) do
        [
          and: %Schema{
            type: :array,
            items: %Reference{
              "$ref": "#/components/schemas/#{AshJsonApi.Resource.Info.type(resource)}-filter"
            },
            uniqueItems: true
          },
          or: %Schema{
            type: :array,
            items: %Reference{
              "$ref": "#/components/schemas/#{AshJsonApi.Resource.Info.type(resource)}-filter"
            },
            uniqueItems: true
          },
          not: %Reference{
            "$ref": "#/components/schemas/#{AshJsonApi.Resource.Info.type(resource)}-filter"
          }
        ]
      else
        []
      end
    end

    defp restrict_for_lists(operators, {:array, _}) do
      list_predicates = [Ash.Query.Operator.IsNil, Ash.Query.Operator.Has]
      Enum.filter(operators, &(&1 in list_predicates))
    end

    defp restrict_for_lists(operators, _), do: operators

    defp constraints_to_item_constraints(
           {:array, _},
           %Ash.Resource.Attribute{
             constraints: constraints,
             allow_nil?: allow_nil?
           } = attribute
         ) do
      %{
        attribute
        | constraints: [
            items: constraints,
            nil_items?: allow_nil? || AshJsonApi.JsonSchema.embedded?(attribute.type)
          ]
      }
    end

    defp constraints_to_item_constraints(_, attribute_or_aggregate), do: attribute_or_aggregate

    defp get_expressable_types(operator_or_function, field_type, array_type?) do
      if :attributes
         |> operator_or_function.__info__()
         |> Keyword.get_values(:behaviour)
         |> List.flatten()
         |> Enum.any?(&(&1 == Ash.Query.Operator)) do
        do_get_expressable_types(operator_or_function.types(), field_type, array_type?)
      else
        do_get_expressable_types(operator_or_function.args(), field_type, array_type?)
      end
    end

    defp do_get_expressable_types(operator_types, field_type, array_type?) do
      field_type_short_name =
        case Ash.Type.short_names()
             |> Enum.find(fn {_, type} -> type == field_type end) do
          nil -> nil
          {short_name, _} -> short_name
        end

      operator_types
      |> Enum.filter(fn
        [:any, {:array, type}] when is_atom(type) ->
          true

        [{:array, inner_type}, :same] when is_atom(inner_type) and array_type? ->
          true

        :same ->
          true

        :any ->
          true

        [:any, type] when is_atom(type) ->
          true

        [^field_type_short_name, type] when is_atom(type) and not is_nil(field_type_short_name) ->
          true

        _ ->
          false
      end)
    end

    defp filter_fields(
           operator,
           type,
           array_type?,
           attribute_or_aggregate,
           resource,
           acc
         ) do
      expressable_types = get_expressable_types(operator, type, array_type?)

      if Enum.any?(expressable_types, &(&1 == :same)) do
        {schema, acc} = resource_attribute_type(attribute_or_aggregate, resource, acc)
        {[{operator.name(), schema}], acc}
      else
        type =
          case Enum.at(expressable_types, 0) do
            [{:array, :any}, :same] ->
              {:unwrap, type}

            [_, {:array, :same}] ->
              {:array, type}

            [_, :same] ->
              type

            [_, :any] ->
              Ash.Type.String

            [_, type] when is_atom(type) ->
              Ash.Type.get_type(type)

            _ ->
              nil
          end

        if type do
          {type, attribute_or_aggregate} =
            case type do
              {:unwrap, type} ->
                {:array, type} = type
                {type, %{attribute_or_aggregate | type: type, constraints: []}}

              type ->
                {type, %{attribute_or_aggregate | type: type, constraints: []}}
            end

          if AshJsonApi.JsonSchema.embedded?(type) do
            {[], acc}
          else
            attribute_or_aggregate = constraints_to_item_constraints(type, attribute_or_aggregate)
            {schema, acc} = resource_attribute_type(attribute_or_aggregate, resource, acc)
            {[{operator.name(), schema}], acc}
          end
        else
          {[], acc}
        end
      end
    end

    defp filterable?(%Ash.Resource.Aggregate{} = aggregate, resource) do
      attribute =
        with field when not is_nil(field) <- aggregate.field,
             related when not is_nil(related) <-
               Ash.Resource.Info.related(resource, aggregate.relationship_path),
             attr when not is_nil(attr) <- Ash.Resource.Info.field(related, aggregate.field) do
          attr
        end

      field_type =
        if attribute do
          attribute.type
        end

      field_constraints =
        if attribute do
          attribute.constraints
        end

      {:ok, type, constraints} =
        Aggregate.kind_to_type(aggregate.kind, field_type, field_constraints)

      filterable?(
        %Ash.Resource.Attribute{name: aggregate.name, type: type, constraints: constraints},
        resource
      )
    end

    defp filterable?(%{type: {:array, _}}, _), do: false
    defp filterable?(%{filterable?: false}, _), do: false
    defp filterable?(%{type: Ash.Type.Union}, _), do: false

    defp filterable?(%Ash.Resource.Calculation{type: type, calculation: {module, _opts}}, _) do
      !AshJsonApi.JsonSchema.embedded?(type) && function_exported?(module, :expression, 2)
    end

    defp filterable?(%{type: type} = attribute, resource) do
      if Ash.Type.NewType.new_type?(type) do
        filterable?(
          %{
            attribute
            | constraints: Ash.Type.NewType.constraints(type, attribute.constraints),
              type: Ash.Type.NewType.subtype_of(type)
          },
          resource
        )
      else
        !AshJsonApi.JsonSchema.embedded?(type)
      end
    end

    defp filterable?(_, _), do: false

    defp all_paths(keyword, trail \\ []) do
      keyword
      |> List.wrap()
      |> Enum.flat_map(fn
        {key, rest} ->
          further =
            rest
            |> List.wrap()
            |> all_paths(trail ++ [key])

          [trail ++ [key]] ++ further

        key ->
          [trail ++ [key]]
      end)
    end

    defp csv_regex(values) do
      values = Enum.join(values, "|")

      "^(#{values})(,(#{values}))*$"
    end

    defp add_null_for_non_required(%Schema{required: required} = schema)
         when is_list(required) do
      Map.update!(schema, :properties, fn
        properties when is_map(properties) ->
          Enum.reduce(properties, %{}, fn {key, value}, acc ->
            if Enum.member?(required, key) do
              Map.put(acc, key, value)
            else
              {description, value} =
                case value do
                  value when is_struct(value) ->
                    {Map.get(value, :description), Map.put(value, :description, nil)}

                  value ->
                    {Map.get(value, "description", Map.get(value, :description)),
                     Map.drop(value, [:description, "description"])}
                end

              new_value =
                %{
                  "anyOf" => [
                    %{
                      "type" => "null"
                    },
                    value
                  ]
                }
                |> then(fn new_value ->
                  case description do
                    nil -> new_value
                    description -> Map.put(new_value, "description", description)
                  end
                end)
                |> unwrap_any_of()

              Map.put(
                acc,
                key,
                new_value
              )
            end
          end)

        properties ->
          properties
      end)
    end
  end
end
