require 'trinidad/configuration'
require 'trinidad/web_app'

module Trinidad
  class Server
    attr_reader :config

    def initialize(config = Trinidad.configuration)
      configure(config)
    end

    def configure(config = Trinidad.configuration)
      configure_logging config[:logging] || config[:log]
      @config = config.freeze
    end

    def hosts
      @hosts ||= @config[:hosts]
    end
    attr_writer :hosts

    def app_base
      @app_base ||= @config[:app_base] || @config[:apps_base]
    end
    attr_writer :app_base

    def web_apps
      @web_apps ||= @config[:web_apps] || @config[:webapps]
    end
    attr_writer :web_apps

    def trap?
      @trap = !! @config[:trap] if ! defined?(@trap) || @trap.nil?
      @trap
    end
    attr_writer :trap

    def ssl_enabled?
      if ! defined?(@ssl_enabled) || @ssl_enabled.nil?
        ssl = @config.key?(:https) ? @config[:https] : @config[:ssl]
        @ssl_enabled = ( !! ssl && ( ! ssl.respond_to?(:empty?) || ! ssl.empty? ) )
      end
      @ssl_enabled
    end
    attr_writer :ssl_enabled
    alias_method :https_enabled?, :ssl_enabled?
    alias_method :https_enabled=, :ssl_enabled=

    def ajp_enabled?
      if ! defined?(@ajp_enabled) || @ajp_enabled.nil?
        ajp = @config[:ajp]
        @ajp_enabled = ( !! ajp && ( ! ajp.respond_to?(:empty?) || ! ajp.empty? ) )
      end
      @ajp_enabled
    end
    attr_writer :ajp_enabled

    def http_configured?
      if ! defined?(@http_configured) || @http_configured.nil?
        http = @config[:http]
        @http_configured = ( !! http && ( ! http.respond_to?(:empty?) || ! http.empty? ) )
      end
      @http_configured
    end
    # @deprecated
    def http_configured=(flag); @http_configured = flag end

    def tomcat; @tomcat ||= initialize_tomcat; end

    def initialize_tomcat
      set_system_properties

      tomcat = Tomcat.new # @see Trinidad::Tomcat
      tomcat.base_dir = config[:base_dir] || Dir.pwd
      if config.key?(:address)
        address = config[:address]; address = '0.0.0.0' if address == '*'
      end
      tomcat.hostname = address || 'localhost'
      tomcat.server.address = address || nil unless address.nil?
      tomcat.port = config[:port].to_i if config.key?(:port)

      set_tomcat_class_loader(tomcat)

      default_host(tomcat)
      create_hosts(tomcat)
      tomcat.enable_naming

      http_connector = http_configured? || ( ! ajp_enabled? && ! ssl_enabled? )

      tomcat.connector = add_http_connector(config[:http], tomcat) if http_connector

      if ssl_enabled?
        options = config.key?(:https) ? config[:https] : config[:ssl]
        options = {} if options == true
        unless options.key?(:port)
          options[:port] = http_connector ? 3443 : config[:port] || 3443
        end
        connector = add_ssl_connector(options, tomcat)
        tomcat.connector = connector unless http_connector
        http_connector = true # tomcat.connector http: or https: or ajp:
      end

      if ajp_enabled?
        options = config[:ajp]; options = {} if options == true
        unless options.key?(:port)
          options[:port] = config[:port] || 8009 unless http_connector
        end
        connector = add_ajp_connector(options, tomcat)
        tomcat.connector = connector unless http_connector
      end

      Extensions.configure_server_extensions(config[:extensions], tomcat)
    end
    protected :initialize_tomcat

    def add_host_monitor(app_holders)
      for host in tomcat.engine.find_children
        host_apps = select_host_apps(app_holders, host, tomcat)
        host.add_lifecycle_listener(Lifecycle::Host.new(self, *host_apps))
      end
    end
    protected :add_host_monitor

    def add_ajp_connector(options = config[:ajp], tomcat = nil)
      # backwards compatibility - single argument (tomcat = @tomcat)
      if options && ! options.respond_to?(:[])
        tomcat = options; options = config[:ajp]
      else
        tomcat = @tomcat
      end if tomcat.nil?

      options = options.respond_to?(:[]) ? options.dup : {}
      options[:address] = config[:address] if ! options.key?(:address) && config.key?(:address)

      add_service_connector(options, 'AJP/1.3', tomcat)
    end

    def add_http_connector(options = config[:http], tomcat = nil)
      # backwards compatibility - single argument (tomcat = @tomcat)
      if options && ! options.respond_to?(:[])
        tomcat = options; options = config[:http]
      else
        tomcat = @tomcat
      end if tomcat.nil?

      options = options.respond_to?(:[]) ? options.dup : {}
      options[:port] = config[:port] || 3000 unless options.key?(:port)
      options[:address] = config[:address] if ! options.key?(:address) && config.key?(:address)

      if options.delete(:nio)
        options[:protocol_handler] ||= 'org.apache.coyote.http11.Http11NioProtocol'
      end

      if options.delete(:apr)
        tomcat.server.add_lifecycle_listener(Tomcat::AprLifecycleListener.new)
      end

      add_service_connector(options, 'HTTP/1.1', tomcat)
    end

    # @private
    DEFAULT_KEYSTORE_FILE = 'ssl/keystore' # TODO review default location

    def add_ssl_connector(options = config[:ssl], tomcat = nil)
      # backwards compatibility - single argument (tomcat = @tomcat)
      if options && ! options.respond_to?(:[])
        tomcat = options; options = config[:ssl]
      else
        tomcat = @tomcat
      end if tomcat.nil?

      options = { :scheme => 'https', :secure => true }.merge!( options.respond_to?(:[]) ? options : {} )
      options[:address] = config[:address] if ! options.key?(:address) && config.key?(:address)

      if keystore_file = options.delete(:keystore) || options.delete(:keystore_file)
        options[:keystoreFile] ||= keystore_file
      end
      options[:keystorePass] ||= options.delete(:keystore_pass) if options.key?(:keystore_pass)
      options[:keystoreType] ||= options.delete(:keystore_type) if options.key?(:keystore_type)
      # handle "custom" alternative SSL (casing) options :
      options[:SSLEnabled] = options.delete(:ssl_enabled) || true # always true
      options[:SSLCertificateFile] ||= options.delete(:ssl_certificate_file) if options.key?(:ssl_certificate_file)
      options[:SSLCertificateKeyFile] ||= options.delete(:ssl_certificate_key_file) if options.key?(:ssl_certificate_key_file)
      options[:SSLVerifyClient] ||= options.delete(:ssl_verify_client) if options.key?(:ssl_verify_client)
      options[:SSLProtocol] ||= options.delete(:ssl_protocol) if options.key?(:ssl_protocol)
      # NOTE: there's quite more SSL prefixed options with APR ...

      if ! options[:keystoreFile] && ! options[:SSLCertificateFile]
        # generate one for development/testing SSL :
        options[:keystoreFile] = DEFAULT_KEYSTORE_FILE
        options[:keystorePass] ||= 'waduswadus42' # NOTE change/ask for default
        if File.exist?(DEFAULT_KEYSTORE_FILE)
          logger.info "Using (default) keystore at #{DEFAULT_KEYSTORE_FILE.inspect}"
        else
          generate_default_keystore(options)
        end
      end

      add_service_connector(options, 'HTTP/1.1', tomcat)
    end

    # NOTE: make sure to pass an options Hash that might be changed !
    def add_service_connector(options, protocol = nil, tomcat = @tomcat)
      connector = Tomcat::Connector.new(options.delete(:protocol) || protocol)
      connector.scheme = options.delete(:scheme) if options.key?(:scheme)
      connector.secure = options.key?(:secure) ? options.delete(:secure) : false
      connector.port = options.delete(:port).to_i if options[:port]

      if handler = options.delete(:protocol_handler) || options.delete(:protocol_handler_class_name)
        connector.protocol_handler_class_name = handler
      end

      options.each { |key, value| connector.setProperty(key.to_s, value.to_s) }

      tomcat.service.add_connector(connector)
      connector
    end
    private :add_service_connector

    def add_web_app(web_app, host = nil, start = nil)
      host ||= begin
        name = web_app.host_name
        name ? find_host(name, tomcat) : tomcat.host
      end
      prev_start = host.start_children
      context = begin
        host.start_children = start unless start.nil?
        # public Context addWebapp(Host host, String url, String name, String docBase)
        context_path = web_app.context_path; context_path = '' if context_path == '/'
        tomcat.addWebapp(host, context_path, web_app.context_name, web_app.root_dir)
      rescue Java::JavaLang::IllegalArgumentException => e
        if e.message =~ /addChild\:/
          context_name = web_app.context_name
          logger.error "could not add application #{context_name.inspect} from #{web_app.root_dir}\n" <<
                       " (same context name is used for #{host.find_child(context_name).doc_base})"
          raise "there's already an application named #{context_name.inspect} for host #{host.name.inspect}"
        end
        raise e
      ensure
        host.start_children = prev_start unless start.nil?
      end
      Extensions.configure_webapp_extensions(web_app.extensions, tomcat, context)
      if lifecycle = web_app.define_lifecycle
        context.add_lifecycle_listener(lifecycle)
      end
      context
    end

    def deploy_web_apps(tomcat = self.tomcat)
      web_apps = create_web_apps
      add_host_monitor web_apps
      web_apps
    end

    def start
      deploy_web_apps(tomcat)

      trap_signals if trap?

      tomcat.start
      tomcat.server.await
    end

    def start!
      if defined?(@tomcat) && @tomcat
        @tomcat.destroy; @tomcat = nil
      end
      start
    end

    def stop
      if defined?(@tomcat) && @tomcat
        @tomcat.stop; true
      end
    end

    def stop!
      (@tomcat.destroy; true) if stop
    end

    protected

    def create_web_apps
      # add default web app if needed :
      if ! web_apps && ! app_base && ! hosts
        default_app = { :context_path => config[:context_path] }
        root_dir = web_app_root_dir(config) || Dir.pwd
        default_app[:root_dir] = root_dir if root_dir != false
        default_app[:rackup] = config[:rackup] if config[:rackup]

        self.web_apps = { :default => default_app }
      end

      apps = []

      # configured :web_apps
      web_apps.each do |name, app_config|
        app_config[:context_name] ||= name
        apps << ( app_holder = create_web_app(app_config) ); app = app_holder.web_app
        logger.info "Deploying from #{app.root_dir} as #{app.context_path}"
      end if web_apps

      # configured :app_base or :hosts - scan for applications in host's app_base directory :
      tomcat.engine.find_children.each do |host|
        apps_path = java.io.File.new(host.app_base).list.to_a
        if host.deploy_ignore # respect deploy ignore pattern (even if not deploying on startup)
          deploy_ignore_pattern = Regexp.new(host.deploy_ignore)
          apps_path.reject! { |path| path =~ deploy_ignore_pattern }
        end
        # we do a bit of "default" filtering for hosts of our own :
        work_dir = host.work_dir
        apps_path.reject! do |path|
          if path[0, 1] == '.' then true # ignore "hidden" files
          elsif work_dir && work_dir == path then true
          elsif ! work_dir && path =~ /tomcat\.\d+$/ then true # [host_base]/tomcat.8080
          elsif path[-4..-1] == '.war' && apps_path.include?(path[0...-4]) # only keep expanded .war
            logger.info "Expanded .war at #{path} - only deploying directory (.war ignored)"
            true
          end
        end

        apps_path.each do |path| # host web apps (from dir or .war files)
          app_root = File.expand_path(path, host.app_base)
          if File.directory?(app_root) || ( app_root[-4..-1] == '.war' )
            app_base_name = File.basename(app_root)
            deployed = apps.find do |app_holder|; web_app = app_holder.web_app
              web_app.root_dir == app_root ||
                web_app.context_path == Tomcat::ContextName.new(app_base_name).path
            end
            if deployed
              logger.debug "Skipping auto-deploy from #{app_root} (already deployed)"
            else
              apps << ( app_holder = create_web_app({
                :context_name => path, :root_dir => app_root, :host_name => host.name
              }) ); app = app_holder.web_app
              logger.info "Auto-Deploying from #{app.root_dir} as #{app.context_path}"
            end
          end
        end
      end if app_base || hosts

      apps
    end

    def create_web_app(app_config)
      host_name = app_config[:host_name] || tomcat.host.name
      host = tomcat.engine.find_child(host_name)
      app_config[:root_dir] = web_app_root_dir(app_config, host)

      web_app = WebApp.create(app_config, config)
      WebApp::Holder.new(web_app, add_web_app(web_app))
    end

    def create_hosts(tomcat = @tomcat)
      hosts.each do |app_base, host_config|
        next if app_base == :default # @see #default_host
        if host = find_host(app_base, host_config, tomcat)
          setup_host(app_base, host_config, host)
        else
          create_host(app_base, host_config, tomcat)
        end
      end if hosts

      default_host = tomcat.host
      default_app_base = ( default_host.app_base == DEFAULT_HOST_APP_BASE )
      if self.app_base ||
        ( default_app_base && ! File.exists?(DEFAULT_HOST_APP_BASE) )
        tomcat.host.app_base = self.app_base || Dir.pwd
      end

      web_app_hosts = []
      # create hosts as they are specified for each app in :web_apps :
      # e.g. :app1 => { :root_dir => 'app1', :host => 'virtual.host' }
      web_apps.each do |_, app_config|
        if host_names = app_config[:hosts] || app_config[:host]
          if host = find_host(host_names, tomcat)
            app_root = web_app_root_dir(app_config, host)
            set_host_app_base(app_root, host, default_host, web_app_hosts)
          else
            app_root = web_app_root_dir(app_config)
            raise "no root for app #{app_config.inspect}" unless app_root
            app_root = File.expand_path(app_root)
            # for created hosts -> web-app per host by default
            # thus new host's app_base will point to root_dir :
            host = create_host(app_root, host_names, tomcat)
            web_app_hosts << host
          end
          app_config[:host_name] = host.name
        end
      end if web_apps
    end

    Tomcat::StandardHost.send :field_writer, :appBase

    def create_host(app_base, host_config, tomcat = @tomcat)
      host = Tomcat::StandardHost.new
      host.appBase = nil # reset default app_base
      host.deployXML = false # disabled by default
      setup_host(app_base, host_config, host)
      tomcat.engine.add_child host if tomcat
      host
    end

    def setup_host(app_base, host_config, host)
      if host_config.is_a?(Array)
        name = host_config.shift
        host_config = { :name => name, :aliases => host_config }
      elsif host_config.is_a?(String) || host_config.is_a?(Symbol)
        host_config = { :name => host_config }
      else
        host_config[:name] ||= app_base
      end
      host_config[:app_base] ||= app_base if app_base.is_a?(String)

      host_config.each do |name, value|
        case (name = name.to_sym)
        when :app_base
          host.app_base = value if default_host_base?(host)
        when :aliases
          aliases = host.find_aliases || []
          value.each do |aliaz|
            next if (aliaz = aliaz.to_s) == host.name
            host.add_alias(aliaz) unless aliases.include?(aliaz)
          end if host_config[:aliases]
        else
          value = value.to_s if value.is_a?(Symbol)
          host.send("#{name}=", value) # e.g. host.name = value
        end
      end
    end

    def set_system_properties(system = Java::JavaLang::System)
      system.set_property("org.apache.catalina.startup.EXIT_ON_INIT_FAILURE", 'true')
    end

    def set_tomcat_class_loader(tomcat)
      # NOTE: allows for Java class-resolution to work from within Tomcat :
      tomcat_loader = tomcat.server.getParentClassLoader
      if tomcat_loader.nil? || tomcat_loader == Java::JavaLang::ClassLoader.getSystemClassLoader
        tomcat.server.setParentClassLoader JRuby.runtime.jruby_class_loader
      end
    end

    def configure_logging(logging)
      Trinidad::Logging.configure(logging)
    end

    def logger; @logger ||= self.class.logger; end

    def self.logger
      Logging::LogFactory.getLog('org.apache.catalina.startup.Tomcat')
    end

    private

    def default_host(tomcat = @tomcat)
      host = tomcat.host # make sure we initialize default host
      host.deployXML = false
      host_config = @config[:host] || ( @config[:hosts] && @config[:hosts][:default] )
      if host_config.is_a?(String)
        host.name = host_config
      elsif host_config
        host_config.each { |name, value| host.send("#{name}=", value) }
      end
      host
    end

    # @private
    DEFAULT_HOST_APP_BASE = 'webapps'

    def default_host_base?(host)
      host.app_base.nil? || ( host.app_base == DEFAULT_HOST_APP_BASE && host.name == 'localhost' )
    end

    def set_host_app_base(app_root, host, default_host, web_app_hosts)
      if host.app_base # we'll try setting a common parent :
        require 'pathname'; app_path = Pathname.new(app_root)
        base_path = Pathname.new(host.app_base)
        unless app_path.exist?
          app_path = app_path.relative_path_from(base_path) rescue app_path
        end
        app_real_path = begin; app_path.realpath.to_s; rescue
          logger.warn "Application root #{app_root} does not exist !"
          return
        end
        base_parent = false
        2.times do
          begin
            if app_real_path.index(base_path.realpath.to_s) == 0
              base_parent = true; break
            end
          rescue => e
            logger.warn "Host #{host.name.inspect} app_base does not exist," <<
            " try configuring an absolute path or create it\n (#{e.message})"
            return
          end
          base_path = base_path.parent
        end
        if base_parent
          return if base_path.to_s == host.app_base
          host.app_base = base_path.realpath.to_s
          unless web_app_hosts.include?(host)
            logger.info "Changing (configured) app_base for host #{host.name.inspect}" <<
                        " (#{host.app_base}) to include application root: #{app_path}"
          end
        else
          logger.warn "Host #{host.name.inspect} app_base #{host.app_base.inspect}" <<
                      " is not a parent directory for application root: #{app_path}"
        end
      else
        host.app_base = app_path.parent.realpath.to_s
      end
    end

    def select_host_apps(app_holders, host, tomcat = self.tomcat)
      app_holders.select do |app_holder|
        host_name = app_holder.web_app.host_name
        host_name ? host_name == host.name : host == tomcat.host # default host
      end
    end

    def find_host(name, host_config, tomcat = nil)
      if tomcat.nil? # assume 2 args (host_config, tomcat)
        tomcat = host_config; host_config = name
      end

      if host_config.is_a?(Array)
        names = host_config
      elsif host_config.is_a?(String) || host_config.is_a?(Symbol)
        names = [ host_config ]
      elsif host_config # :localhost => { :aliases => 'local,127.0.0.1' ... }
        names = [ host_config[:name] ||= name ]
        aliases = host_config[:aliases]
        if aliases && ! aliases.is_a?(Array)
          aliases = aliases.split(',').each(&:strip!)
          host_config[:aliases] = aliases
        end
      else # only name passed :
        return tomcat.engine.find_child(name.to_s)
      end

      hosts = tomcat.engine.find_children
      for name in names # host_names
        host = hosts.find do |host|
          host.name == name || (host.aliases || []).include?(name)
        end
        return host if host
      end
      nil
    end

    def web_app_root_dir(config, host = nil)
      path = config[:root_dir] || config[:web_app_dir] || begin
        path = config[:context_path]
        ( path && path[0, 1] == '/' ) ? path[1..-1] : path
      end || ( config[:context_name] ? config[:context_name].to_s : nil )

      return nil if path.nil?
      return File.expand_path(path) if File.exist?(path)

      if host
        base = host.app_base
        ( path && base ) ? File.join(base, path) : path
      else
        path
      end
    end

    def generate_default_keystore(file, pass = nil) # or (config)
      file, pass = file[:keystoreFile], file[:keystorePass] if pass.nil?
      file = Java::JavaIo::File.new(file)
      keystore_dir = file.parent_file
      if ! keystore_dir.exists && ! keystore_dir.mkdir
        raise "Unable to create keystore folder: #{keystore_dir.canonical_path}"
      end

      key_tool_args = [ "-genkey",
        "-alias", "localhost",
        "-dname", dname = "CN=localhost,OU=Trinidad,O=Trinidad,C=ES",
        "-keyalg", "RSA",
        "-validity", "365",
        "-storepass", "key",
        "-keystore", file.absolute_path,
        "-storepass", pass,
        "-keypass", pass ]

      logger.info "Generating a (default) keystore for localhost #{dname.inspect} at " <<
                  "#{file.canonical_path} (password: '#{pass}')"

      key_tool = nil
      begin
        key_tool = Java::SunSecurityTools::KeyTool
      rescue NameError # Java 8 seems to have removed it completely
        `keytool #{key_tool_args.join(' ')}` # NOTE: use for all JDK versions
      else
        key_tool.main key_tool_args.to_java(:string)
      end
    end

    def trap_signals
      trap('INT') { stop! }
      trap('TERM') { stop! }
    end

  end
end
