require 'uri'
require 'js_routes/engine' if defined?(Rails)
require 'js_routes/version'

class JsRoutes

  #
  # OPTIONS
  #

  DEFAULT_PATH = File.join('app','assets','javascripts','routes.js')

  DEFAULTS = {
    namespace: "Routes",
    exclude: [],
    include: //,
    file: DEFAULT_PATH,
    prefix: nil,
    url_links: false,
    camel_case: false,
    default_url_options: {},
    compact: false,
    serializer: nil,
    special_options_key: "_options",
  }

  NODE_TYPES = {
    GROUP: 1,
    CAT: 2,
    SYMBOL: 3,
    OR: 4,
    STAR: 5,
    LITERAL: 6,
    SLASH: 7,
    DOT: 8
  }

  LAST_OPTIONS_KEY = "options".freeze
  FILTERED_DEFAULT_PARTS = [:controller, :action, :subdomain]
  URL_OPTIONS = [:protocol, :domain, :host, :port, :subdomain]

  class Options < Struct.new(*DEFAULTS.keys)
    def to_hash
      Hash[*members.zip(values).flatten(1)].symbolize_keys
    end
  end

  #
  # API
  #

  class << self
    def setup(&block)
      options.tap(&block) if block
    end

    def options
      @options ||= Options.new.tap do |opts|
        DEFAULTS.each_pair {|k,v| opts[k] = v}
      end
    end

    def generate(opts = {})
      new(opts).generate
    end

    def generate!(file_name=nil, opts = {})
      if file_name.is_a?(Hash)
        opts = file_name
        file_name = opts[:file]
      end
      new(opts).generate!(file_name)
    end

    # Under rails 3.1.1 and higher, perform a check to ensure that the
    # full environment will be available during asset compilation.
    # This is required to ensure routes are loaded.
    def assert_usable_configuration!
      if 3 == Rails::VERSION::MAJOR && !Rails.application.config.assets.initialize_on_precompile
        raise("Cannot precompile js-routes unless environment is initialized. Please set config.assets.initialize_on_precompile to true.")
      end
      true
    end

    def json(string)
      ActiveSupport::JSON.encode(string)
    end
  end

  #
  # Implementation
  #

  def initialize(options = {})
    @options = self.class.options.to_hash.merge(options)
  end

  def generate
    # Ensure routes are loaded. If they're not, load them.
    if named_routes.to_a.empty? && application.respond_to?(:reload_routes!)
      application.reload_routes!
    end

    {
      "GEM_VERSION"         => JsRoutes::VERSION,
      "APP_CLASS"           => application.class.to_s,
      "NAMESPACE"           => @options[:namespace],
      "DEFAULT_URL_OPTIONS" => json(@options[:default_url_options].merge(deprecate_url_options)),
      "PREFIX"              => @options[:prefix] || Rails.application.config.relative_url_root || "",
      "NODE_TYPES"          => json(NODE_TYPES),
      "SERIALIZER"          => @options[:serializer] || json(nil),
      "ROUTES"              => js_routes,
      "SPECIAL_OPTIONS_KEY" => @options[:special_options_key].to_s,
      "DEPRECATED_BEHAVIOR" => Rails.version < "4",
    }.inject(File.read(File.dirname(__FILE__) + "/routes.js")) do |js, (key, value)|
      js.gsub!(key, value.to_s)
    end
  end

  def deprecate_url_options
    result = {}
    if @options.key?(:default_format)
      warn("default_format option is deprecated. Use default_url_options = { format: <format> } instead")
      result.merge!(  format: @options[:default_format]  )
    end
    if @options[:url_links].is_a?(String)
      ActiveSupport::Deprecation.warn('js-routes url_links config value must be a boolean. Use default_url_options for specifying a default host.')

      raise "invalid URL format in url_links (ex: http[s]://example.com)" if @options[:url_links].match(URI::Parser.new.make_regexp(%w(http https))).nil?
      uri = URI.parse(@options[:url_links])
      default_port = uri.scheme == "https" ? 443 : 80
      port = uri.port == default_port ? nil : uri.port
      result.merge!(
        host: uri.host,
        port: port,
        protocol: uri.scheme,
      )
    end
    result
  end

  def generate!(file_name = nil)
    # Some libraries like Devise do not yet loaded their routes so we will wait
    # until initialization process finish
    # https://github.com/railsware/js-routes/issues/7
    Rails.configuration.after_initialize do
      file_name ||= self.class.options['file']
      File.open(Rails.root.join(file_name || DEFAULT_PATH), 'w') do |f|
        f.write generate
      end
    end
  end

  protected

  def application
    @options[:application] || Rails.application
  end

  def named_routes
    application.routes.named_routes.to_a
  end

  def js_routes
    js_routes = named_routes.sort_by(&:first).flat_map do |_, route|
      [build_route_if_match(route)] + mounted_app_routes(route)
    end.compact
    "{\n" + js_routes.join(",\n") + "}\n"
  end

  def mounted_app_routes(route)
    rails_engine_app = get_app_from_route(route)
    if rails_engine_app.respond_to?(:superclass) && rails_engine_app.superclass == Rails::Engine && !route.path.anchored
      rails_engine_app.routes.named_routes.map do |_, engine_route|
        build_route_if_match(engine_route, route)
      end
    else
      []
    end
  end

  def get_app_from_route(route)
    # rails engine in Rails 4.2 use additional ActionDispatch::Routing::Mapper::Constraints, which contain app
    if route.app.respond_to?(:app) && route.app.respond_to?(:constraints)
      route.app.app
    else
      route.app
    end
  end

  def build_route_if_match(route, parent_route=nil)
    if any_match?(route, parent_route, @options[:exclude]) || !any_match?(route, parent_route, @options[:include])
      nil
    else
      build_js(route, parent_route)
    end
  end

  def any_match?(route, parent_route, matchers)
    full_route = [parent_route.try(:name), route.name].compact.join('_')

    matchers = Array(matchers)
    matchers.any? {|regex| full_route =~ regex}
  end

  def build_js(route, parent_route)
    name = [parent_route.try(:name), route.name].compact
    route_name = generate_route_name(name, (:path unless @options[:compact]))
    parent_spec = parent_route.try(:path).try(:spec)
    route_arguments = route_js_arguments(route, parent_spec)
    url_link = generate_url_link(name, route_name, route_arguments, route)
    _ = <<-JS.strip!
  // #{name.join('.')} => #{parent_spec}#{route.path.spec}
  // function(#{build_params(route.required_parts)})
  #{route_name}: Utils.route(#{route_arguments})#{",\n" + url_link if url_link.length > 0}
  JS
  end

  def route_js_arguments(route, parent_spec)
    required_parts = route.required_parts
    parts_table = route.parts.each_with_object({}) do |part, hash|
      hash[part] = required_parts.include?(part)
    end
    default_options = route.defaults.select do |part, _|
      FILTERED_DEFAULT_PARTS.exclude?(part) && URL_OPTIONS.include?(part) || parts_table[part]
    end
    [
      # JS objects don't preserve the order of properties which is crucial,
      # so array is a better choice.
      parts_table.to_a,
      default_options,
      serialize(route.path.spec, parent_spec)
    ].map do |argument|
      json(argument)
    end.join(", ")
  end

  def generate_url_link(name, route_name, route_arguments, route)
    return "" unless @options[:url_links]
    <<-JS.strip!
    #{generate_route_name(name, :url)}: Utils.route(#{route_arguments}, true)
    JS
  end

  def generate_route_name(name, suffix)
    route_name = name.join('_')
    route_name << "_#{ suffix }" if suffix
    @options[:camel_case] ? route_name.camelize(:lower) : route_name
  end

  def json(string)
    self.class.json(string)
  end

  def build_params(required_parts)
    params = required_parts + [LAST_OPTIONS_KEY]
    params.join(", ")
  end

  # This function serializes Journey route into JSON structure
  # We do not use Hash for human readable serialization
  # And preffer Array serialization because it is shorter.
  # Routes.js file will be smaller.
  def serialize(spec, parent_spec=nil)
    return nil unless spec
    return spec.tr(':', '') if spec.is_a?(String)
    result = serialize_spec(spec, parent_spec)
    if parent_spec && result[1].is_a?(String)
      result = [
        # We encode node symbols as integer
        # to reduce the routes.js file size
        NODE_TYPES[:CAT],
        serialize_spec(parent_spec),
        result
      ]
    end
    result
  end

  def serialize_spec(spec, parent_spec=nil)
    [
      NODE_TYPES[spec.type],
      serialize(spec.left, parent_spec),
      spec.respond_to?(:right) && serialize(spec.right)
    ]
  end
end

