module SoberSwag
  class Compiler
    ##
    # A compiler for DRY-Struct data types, essentially.
    # It only consumes one type at a time.
    class Type # rubocop:disable Metrics/ClassLength
      class TooComplicatedError < ::SoberSwag::Compiler::Error; end
      class TooComplicatedForPathError < TooComplicatedError; end
      class TooComplicatedForQueryError < TooComplicatedError; end

      METADATA_KEYS = %i[description deprecated].freeze

      def initialize(type)
        @type = type
      end

      attr_reader :type

      ##
      # Is this type standalone, IE, worth serializing on its own
      # in the schemas section of our schema?
      def standalone?
        type.is_a?(Class)
      end

      def object_schema
        @object_schema ||=
          make_object_schema
      end

      def object_schema_meta
        return {} unless standalone? && type <= SoberSwag::Type::Named

        {
          description: type.description
        }.reject { |_, v| v.nil? }
      end

      def schema_stub
        @schema_stub ||= generate_schema_stub
      end

      def path_schema
        path_schema_stub.map do |e|
          ensure_uncomplicated(e[:name], e[:schema])
          e.merge(in: :path)
        end
      rescue TooComplicatedError => e
        raise TooComplicatedForPathError, e.message
      end

      DEFAULT_QUERY_SCHEMA_ATTRS = { in: :query, style: :deepObject, explode: true }.freeze

      def query_schema
        path_schema_stub.map { |e| DEFAULT_QUERY_SCHEMA_ATTRS.merge(e) }
      rescue TooComplicatedError => e
        raise TooComplicatedForQueryError, e.message
      end

      def ref_name
        SoberSwag::Compiler::Primitive.new(type).ref_name
      end

      def found_types
        @found_types ||=
          begin
            (_, found_types) = parsed_result
            found_types
          end
      end

      def mapped_type
        @mapped_type ||= parsed_type.map { |v| SoberSwag::Compiler::Primitive.new(v).type_hash }
      end

      def parsed_type
        @parsed_type ||=
          begin
            (parsed,) = parsed_result
            parsed
          end
      end

      def parsed_result
        @parsed_result ||= Parser.new(type_for_parser).run_parser
      end

      def eql?(other)
        other.class == self.class && other.type == type
      end

      def hash
        [self.class, type].hash
      end

      private

      def generate_schema_stub
        if type.is_a?(Class)
          SoberSwag::Compiler::Primitive.new(type).type_hash
        else
          object_schema
        end
      end

      def type_for_parser
        if type.is_a?(Class)
          type.schema.type
        else
          # Probably a constrained array
          type
        end
      end

      def make_object_schema(metadata_keys: METADATA_KEYS)
        normalize(mapped_type).cata { |e| to_object_schema(e, metadata_keys) }.merge(object_schema_meta)
      end

      def normalize(object)
        object.cata { |e| rewrite_sums(e) }.cata { |e| flatten_one_ofs(e) }
      end

      def rewrite_sums(object) # rubocop:disable Metrics/MethodLength
        case object
        when Nodes::Sum
          lhs, rhs = object.deconstruct
          if lhs.is_a?(Nodes::OneOf) && rhs.is_a?(Nodes::OneOf)
            Nodes::OneOf.new(lhs.deconstruct + rhs.deconstruct)
          elsif lhs.is_a?(Nodes::OneOf)
            Nodes::OneOf.new([*lhs.deconstruct, rhs])
          elsif rhs.is_a?(Nodes::OneOf)
            Nodes::OneOf.new([lhs, *rhs.deconstruct])
          else
            Nodes::OneOf.new([lhs, rhs])
          end
        else
          object
        end
      end

      def flatten_one_ofs(object)
        case object
        when Nodes::OneOf
          Nodes::OneOf.new(object.deconstruct.uniq)
        else
          object
        end
      end

      def to_object_schema(object, metadata_keys = METADATA_KEYS) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
        case object
        when Nodes::List
          { type: :array, items: object.element }
        when Nodes::Enum
          { type: :string, enum: object.values }
        when Nodes::OneOf
          one_of_to_schema(object)
        when Nodes::Object
          # openAPI requires that you give a list of required attributes
          # (which IMO is the *totally* wrong thing to do but whatever)
          # so we must do this garbage
          required = object.deconstruct.filter { |(_, b)| b[:required] }.map(&:first)
          {
            type: :object,
            properties: object.deconstruct.map { |(a, b)|
              [a, b.reject { |k, _| k == :required }]
            }.to_h,
            required: required
          }
        when Nodes::Attribute
          name, req, value, meta = object.deconstruct
          value = value.merge(meta&.select { |k, _| metadata_keys.include?(k) } || {})
          if req
            [name, value.merge(required: true)]
          else
            [name, value]
          end
        when Nodes::Primitive
          object.value.merge(object.metadata.select { |k, _| metadata_keys.include?(k) })
        else
          raise ArgumentError, "Got confusing node #{object} (#{object.class})"
        end
      end

      def one_of_to_schema(object)
        if object.deconstruct.include?({ type: :null })
          rejected = object.deconstruct.reject { |e| e[:type] == :null }
          if rejected.length == 1
            rejected.first.merge(nullable: true)
          else
            { oneOf: flatten_oneofs_hash(rejected), nullable: true }
          end
        else
          { oneOf: flatten_oneofs_hash(object.deconstruct) }
        end
      end

      def flatten_oneofs_hash(object)
        object.map { |h|
          h[:oneOf] || h
        }.flatten
      end

      def path_schema_stub
        @path_schema_stub ||=
          make_object_schema(metadata_keys: METADATA_KEYS | %i[style explode])[:properties].map do |k, v|
            # ensure_uncomplicated(k, v)
            {
              name: k,
              schema: v.reject { |key, _| %i[required nullable explode style].include?(key) },
              required: object_schema[:required].include?(k) || false,
              style: v[:style],
              explode: v[:explode]
            }.reject { |_, v2| v2.nil? }
          end
      end

      def ensure_uncomplicated(key, value)
        return if value[:type]

        return value[:oneOf].each { |member| ensure_uncomplicated(key, member) } if value[:oneOf]

        raise TooComplicatedError, <<~ERROR
          Property #{key} has object-schema #{value}, but this type of param should be simple (IE a primitive of some kind)
        ERROR
      end
    end
  end
end