require 'rails_versioned_routing/railtie' if defined?(Rails)

module RailsVersionedRouting
  VERSION = "1.4.0"

  class VersionConstraint
  end

  class BetaVersionConstraint < VersionConstraint
    def matches?(request)
      accept = request.headers.fetch(:accept, '*/*')
      accept.match(/version=beta/)
    end

    def version
      Float::INFINITY
    end
  end

  class NumberedVersionConstraint < VersionConstraint
    attr_reader :version

    def initialize(options)
      @version = options.fetch(:version)
    end

    def matches?(request)
      accept = request.headers.fetch(:accept, '*/*')
      has_version = accept.match(/version=([\d+])/)

      if has_version
        version = has_version.captures.last.to_i
        return @version <= version
      else
        return @version == 1
      end
    end
  end

  class VersionedGroup
    class Visitor # :nodoc:
      DISPATCH_CACHE = {}

      def accept(node)
        visit(node)
      end

      private

        def visit node
          send(DISPATCH_CACHE[node.type], node)
        end

        def binary(node)
          visit(node.left)
          visit(node.right)
        end
        def visit_CAT(n); binary(n); end

        def nary(node)
          node.children.each { |c| visit(c) }
        end
        def visit_OR(n); nary(n); end

        def unary(node)
          visit(node.left)
        end
        def visit_GROUP(n); unary(n); end
        def visit_STAR(n); unary(n); end

        def terminal(node); end
        def visit_LITERAL(n); terminal(n); end
        def visit_SYMBOL(n);  terminal(n); end
        def visit_SLASH(n);   terminal(n); end
        def visit_DOT(n);     terminal(n); end

        private_instance_methods(false).each do |pim|
          next unless pim =~ /^visit_(.*)$/
          DISPATCH_CACHE[$1.to_sym] = pim
        end
    end

    class OptimizedPath < Visitor
      def accept(node)
        Array(visit(node))
      end

      private

      def visit_CAT(node)
        [visit(node.left), visit(node.right)].flatten
      end

      def visit_SYMBOL(node)
        node.left[1..-1].to_sym
      end

      def visit_STAR(node)
        visit(node.left)
      end

      def visit_GROUP(node)
        []
      end

      %w{ LITERAL SLASH DOT }.each do |t|
        class_eval %{ def visit_#{t}(n); n.left; end }, __FILE__, __LINE__
      end
    end

    def initialize(routes)
      @routes = routes
    end

    def grouped_by_version
      # `versions` is a hash whose empty value is
      # a new, nested hash.
      # The outer hash's keys will all be set to
      # version numbers.
      #
      # The value for each will be a hash.
      # The keys for the inner hash will be string key that
      # combines the routes path and HTTP method.
      #
      # Since higher numbered versions appear first
      # we can rely on the side effect of
      # those path/method pairs being already set
      # so we can safely avoid collisions.
      #
      # routes defined in *earlier* versions will appear
      # in each `versions[version_number]` hash unless
      # the current version has already set a matching
      # path/method key.
      versions = Hash.new {|h,k| h[k] = {}}

      @routes.each do |route|
        # routes that have a constraint added to them appear as
        # an rack 'application' of the type ActionDispatch::Routing::Mapper::Constraints
        if route.app.is_a?(ActionDispatch::Routing::Mapper::Constraints)

          # the constraint might not be a versioned constraint. We handle versioned constraint
          # rack apps different ly
          version_constraint = route.app.constraints.find {|constraint| constraint.is_a?(VersionConstraint) }

          # returns a string representation of the path by walking its
          # AST and building a string
          optimized_path = OptimizedPath.new.accept(route.path.spec)

          if version_constraint
            # transforms the variable names in path into a generic form so we can match the pattern
            # not the specific variable.
            # e.g.
            # get 'posts/:id/hello'
            # get 'posts/:post_id'/hello
            # would generate paths of
            # ['posts', :id, 'hello']
            # and
            # ['posts', :post_id, 'hello']
            # but the routing matching should treat them as
            # ['posts', ANY VARIABLE WE DONT CARE ABOUT NAME, 'hello']
            #
            # So, we transfrom all symbols into the same value
            denormalized_path = denormalize_path(optimized_path)

            # the full key for the hash is a combo of the path and the method
            # since the same path will match different controller/actions if
            # the HTTP method differs.
            denormalize_path_and_method = "#{denormalized_path}-#{route.constraints[:request_method]}"

            version = version_constraint.version

            # add to current version
            versions[version][denormalize_path_and_method] = route

            # add to higher versions unless higher version
            # already includes a path that matches
            versions.each do |k,v|
              next if k <= version

              versions[k][denormalize_path_and_method] ||= route
            end

          else
            versions[0][denormalize_path_and_method] = route
          end
        else
          versions[0][optimized_path] = route
        end
      end

      # flatten one level of the hash
      # {
      #   1 => [<#Route>, <#Route>],
      #   2 => [<#Route>]
      # }
      versions.each do |k,v|
        versions[k] = v.values
      end

      versions
    end

    def denormalize_path(path)
      path.map {|slug| slug.is_a?(Symbol) ? :VARIABLE : slug }
    end
  end

  def beta!(&routes)
    api_constraint = BetaVersionConstraint.new
    scope(module: 'beta', constraints: api_constraint, &routes)
  end

  def version(version_number, &routes)
    api_constraint = NumberedVersionConstraint.new(version: version_number)
    scope(module: "v#{version_number}", constraints: api_constraint, &routes)
  end

  def removed
    @real_match = method(:match)

    # temporarily overrides `match` method in routes file
    def match(*args)
      @real_match.call(args[0], to: Proc.new { raise ActionController::RoutingError.new('Not Found') }, via: args[1][:via])
    end

    yield

    def match(*args)
      @real_match.call(*args)
    end
  end

  def self.group_by_version
    VersionedGroup.new(Rails.application.routes.routes).grouped_by_version
  end
end