# frozen_string_literal: true

require "core/async"

require "pakyow/support/core_refinements/array/ensurable"

require "pakyow/support/hookable"
require "pakyow/support/configurable"
require "pakyow/support/class_state"
require "pakyow/support/deep_dup"
require "pakyow/support/deep_freeze"
require "pakyow/support/definable"
require "pakyow/support/handleable"
require "pakyow/support/pipeline"
require "pakyow/support/deprecatable"

require_relative "behavior/commands"
require_relative "behavior/deprecations"
require_relative "behavior/dispatching"
require_relative "behavior/erroring"
require_relative "behavior/generators"
require_relative "behavior/initializers"
require_relative "behavior/input_parsing"
require_relative "behavior/multiapp"
require_relative "behavior/plugins"
require_relative "behavior/release_channels"
require_relative "behavior/rescuing"
require_relative "behavior/silencing"
require_relative "behavior/timezone"
require_relative "behavior/running"
require_relative "behavior/watching"
require_relative "behavior/restarting"
require_relative "behavior/verifier"

require_relative "actions/input_parser"
require_relative "actions/limiter"
require_relative "actions/logger"
require_relative "actions/missing"
require_relative "actions/normalizer"
require_relative "actions/restart"

require_relative "handleable/actions/handle"
require_relative "handleable/behavior/statuses"
require_relative "handleable/behavior/defaults/not_found"
require_relative "handleable/behavior/defaults/request_too_large"
require_relative "handleable/behavior/defaults/server_error"

# Pakyow environment for running one or more rack apps. Multiple apps can be
# mounted in the environment, each one handling requests at some path.
#
#   Pakyow.configure do
#     mount Pakyow::Application, at: "/"
#   end
#
# = Configuration
#
# The environment can be configured
#
#   Pakyow.configure do
#     config.runnable.server.port = 2001
#   end
#
# It's possible to configure environments differently.
#
#   Pakyow.configure :development do
#     config.runnable.server.host = "pakyow.dev"
#   end
#
# @see Support::Configurable
#
# = Hooks
#
# Hooks can be defined for the following events:
#
#   - load
#   - configure
#   - setup
#   - boot
#   - shutdown
#   - run
#
# Here's how to log a message after boot:
#
#   Pakyow.after "boot" do
#     logger.info "booted"
#   end
#
# @see Support::Hookable
#
# = Logging
#
# The environment contains a global general-purpose logger. It also provides
# a {RequestLogger} instance to each app for logging during a request.
#
# = Setup & Running
#
# The environment can be setup and then run.
#
#   Pakyow.setup(env: :development).run
#
module Pakyow
  using Support::DeepDup
  using Support::DeepFreeze
  using Support::Refinements::Array::Ensurable

  include Support::Hookable
  events :load, :configure, :setup, :boot, :shutdown, :run, :error, :fork

  include Support::Configurable

  envar :pwenv

  setting :default_env, :development
  setting :timezone, :utc
  setting :secrets, ["pakyow"]
  setting :channel, :default
  setting :polite, true

  require "pakyow/connection"
  setting :connection_class, Connection

  setting :root do
    File.expand_path(".")
  end

  setting :lib do
    File.join(config.root, "lib")
  end

  setting :environment_path do
    File.join(config.root, "config/environment")
  end

  setting :loader_path do
    File.join(config.root, "config/loader")
  end

  setting :mounts do
    :all
  end

  defaults :production do
    setting :secrets, [ENV["SECRET"].to_s.strip]
  end

  configurable :runnable do
    setting :formation do
      Runnable::Formation.all
    end

    configurable :server do
      setting :count, 1
      setting :scheme, "http"
      setting :host, "localhost"
      setting :port, 3000

      defaults :production do
        setting :count do
          ENV["WORKERS"] || 5
        end

        setting :host do
          ENV["HOST"] || "0.0.0.0"
        end

        setting :port do
          ENV["PORT"] || 3000
        end
      end
    end

    configurable :watcher do
      setting :enabled, true

      setting :count do
        config.runnable.watcher.enabled ? 1 : 0
      end

      defaults :production do
        setting :enabled, false
      end

      defaults :ludicrous do
        setting :enabled, false
      end
    end
  end

  configurable :cli do
    setting :repl do
      require "irb"
      IRB
    end
  end

  configurable :logger do
    setting :enabled, true
    setting :sync, true

    setting :level do
      if config.logger.enabled
        :debug
      else
        :off
      end
    end

    setting :formatter do
      require "pakyow/logger/formatters/human"
      Logger::Formatters::Human
    end

    setting :destinations do
      if config.logger.enabled
        {stdout: $stdout}
      else
        {}
      end
    end

    defaults :test do
      setting :enabled, false
    end

    defaults :production do
      setting :sync, false

      setting :level do
        if config.logger.enabled
          :info
        else
          :off
        end
      end

      setting :formatter do
        require "pakyow/logger/formatters/logfmt"
        Logger::Formatters::Logfmt
      end
    end

    defaults :ludicrous do
      setting :enabled, false
    end
  end

  configurable :normalizer do
    setting :canonical_uri

    setting :strict_path, true

    setting :strict_www, false
    setting :require_www, true

    setting :strict_https, false
    setting :require_https, true
    setting :allowed_http_hosts, ["localhost"]

    defaults :production do
      setting :strict_https, true
    end
  end

  configurable :limiter do
    setting :length, 0
  end

  configurable :redis do
    configurable :connection do
      setting :url do
        ENV["REDIS_URL"] || "redis://127.0.0.1:6379"
      end

      setting :timeout, 5
      setting :driver, nil
      setting :id, nil
      setting :tcp_keepalive, 5
      setting :reconnect_attempts, 1
      setting :inherit_socket, false
    end

    configurable :pool do
      setting :size, 3
      setting :timeout, 1
    end

    setting :key_prefix, "pw"
  end

  configurable :cookies do
    setting :domain
    setting :path, "/"
    setting :max_age
    setting :expires
    setting :secure
    setting :http_only
    setting :same_site
  end

  include Support::Definable
  include Support::Handleable

  include Behavior::Commands
  include Behavior::Dispatching
  include Behavior::Erroring
  include Behavior::Generators
  include Behavior::Initializers
  include Behavior::InputParsing
  include Behavior::Plugins
  include Behavior::ReleaseChannels
  include Behavior::Rescuing
  include Behavior::Silencing
  include Behavior::Timezone
  include Behavior::Running
  include Behavior::Watching
  include Behavior::Restarting
  include Behavior::Verifier

  include Handleable::Behavior::Statuses

  include Handleable::Behavior::Defaults::NotFound
  include Handleable::Behavior::Defaults::RequestTooLarge
  include Handleable::Behavior::Defaults::ServerError

  include Support::Pipeline

  # These actions are defined first to guarantee they wrap every connection.
  #
  action :handle, Handleable::Actions::Handle
  action :missing, Actions::Missing
  action :log, Actions::Logger
  action :normalize, Actions::Normalizer
  action :parse, Actions::InputParser

  extend Support::ClassState
  class_state :apps, default: []
  class_state :__mounts, default: {}
  class_state :__setups, default: {}
  class_state :frameworks, default: {}
  class_state :__loaded, default: false, reader: false
  class_state :__setup, default: false, reader: false
  class_state :__booted, default: false, reader: false
  class_state :server, default: nil, reader: false
  class_state :env, default: nil, reader: false
  class_state :setup_error, default: nil

  class << self
    include Is::Async

    extend Support::Deprecatable

    # Name of the environment
    #
    attr_reader :env

    # Returns true if the current working directory is a project root.
    #
    def project?
      File.exist?(Pakyow.config.environment_path + ".rb")
    end

    # Global log output.
    #
    # Builds and returns a default global output that's replaced in `setup`.
    #
    def output
      unless defined?(@output)
        require "pakyow/logger/destination"
        require "pakyow/logger/formatters/human"
        @output = Logger::Formatters::Human.new(
          Logger::Destination.new(:stdout, $stdout)
        )
      end

      @output
    end

    # Logger instance for the environment.
    #
    # Builds and returns a default logger that's replaced in `setup`.
    #
    def logger
      unless defined?(@logger)
        require "pakyow/logger/thread_local"
        @logger = Logger::ThreadLocal.new(
          Logger.new("dflt", output: output, level: config.logger.level), key: :pakyow_logger
        )
      end

      @logger
    end

    # Mounts an app at a path. The app can be any object that responds to `call`.
    #
    # @param app [Object] the app object to mount
    # @param at [String] where the endpoint should be mounted
    #
    def mount(app, at:)
      @__mounts[app] = {path: at}
    end

    # Loads the Pakyow environment for the current project.
    #
    def load(env: nil)
      unless loaded?
        @env = (env ||= config.default_env).to_sym

        performing :load do
          if File.exist?(config.loader_path + ".rb")
            Kernel.load config.loader_path + ".rb"
          else
            require "pakyow/integrations/bundler/reset"
            require "pakyow/integrations/bundler/setup"
            require "pakyow/integrations/bootsnap"

            require "pakyow/integrations/bundler/require"
            require "pakyow/integrations/dotenv"

            if File.exist?(config.environment_path + ".rb")
              Kernel.load config.environment_path + ".rb"
            end
          end

          performing :configure do
            configure!(env)
          end

          $LOAD_PATH.unshift(config.lib)
        end

        @__loaded = true
      end
    rescue ApplicationError => error
      raise error
    rescue ScriptError, StandardError => error
      raise EnvironmentError.build(error)
    end

    # Returns true if the environment has loaded.
    #
    def loaded?
      @__loaded == true
    end

    # Prepares the environment for booting by setting up internal state, including applications.
    #
    # @param env [Symbol] the environment to prepare for
    #
    def setup(env: nil)
      unless setup?
        load(env: env)

        performing :setup do
          if config.limiter.length > 0
            action :limit, Actions::Limiter, before: :parse, length: config.limiter.length
          end

          require "console"
          require "console/split"

          require "pakyow/logger"
          require "pakyow/logger/destination"
          require "pakyow/logger/multiplexed"

          destinations = Logger::Multiplexed.new(
            *config.logger.destinations.map { |destination, io|
              io.sync = config.logger.sync
              Logger::Destination.new(destination, io)
            }
          )

          @output = config.logger.formatter.new(destinations)

          # Replace the default logger with a configured logger. We don't overwrite `@logger` here so
          # that objects that hold a reference to the thread local logger before setup still point to
          # the right object and log to the appropriate logger after setup.
          #
          logger.replace(Logger.new("pkyw", output: @output, level: config.logger.level))

          # Setup each app.
          #
          load_apps_common
          mounts.keys.uniq.each do |app|
            setups = @__setups[app]
            if setups.nil? || setups.empty?
              app.setup
            else
              setups.each do |block|
                app.setup(&block)
              end
            end
          end

          @apps = mounts.map { |app, options|
            app.new(mount_path: options[:path])
          }

          @__setup = true
        end
      end

      self
    rescue ApplicationError => error
      raise error
    rescue ScriptError, StandardError => error
      raise EnvironmentError.build(error)
    end

    # Returns true if the environment has been setup.
    #
    def setup?
      @__setup == true
    end

    # Boots the environment so that it can be used, without running it.
    #
    # @param env [Symbol] the environment to prepare for
    #
    def boot(env: nil)
      unless booted?
        setup(env: env)

        performing :boot do
          # Set the environment as booted ahead of telling each app that it is booted. This allows an
          # app's after boot hook to access the booted app through `Pakyow.app`.
          #
          @__booted = true

          # Now tell each app that it has been booted.
          #
          @apps.each(&:booted)
        end
      end

      self
    rescue ApplicationError => error
      raise error
    rescue ScriptError, StandardError => error
      raise EnvironmentError.build(error)
    end

    # Returns true if the environment has booted.
    #
    def booted?
      @__booted == true
    end

    def register_framework(framework_name, framework_module)
      @frameworks[framework_name] = framework_module
    end

    def app(app_name, path: "/", without: [], only: nil, mount: true, &block)
      app_name = app_name.to_sym

      if setup?
        @apps.find { |app|
          app.config.name == app_name
        }
      else
        local = self
        require "pakyow/application"
        app = Pakyow::Application.make(Support::ObjectName.build(app_name, "application")) {
          envar "PWAPP__#{app_name.to_s.upcase}"

          # Change the name only if it's still the default. It's possible the app has already been
          # defined and we're simply extending it.
          #
          config.name = app_name if config.name == :pakyow

          # Including frameworks during make lets frameworks attach `after :make` hooks.
          #
          include_frameworks(*(only || local.frameworks.keys) - Array.ensure(without))
        }

        @__setups[app] ||= []
        @__setups[app] << block if block

        mount_path = if mount
          path
        end

        mount(app, at: mount_path)

        app
      end
    end

    def env?(name)
      env == name.to_sym
    end

    def async(logger: nil)
      target = logger || self.logger.target

      super() do
        Pakyow.logger.set(target)

        yield if block_given?
      end
    end

    def call(input)
      connection = config.connection_class.new(input)

      catch :halt do
        super(connection)
      end

      connection.finalize
    rescue => error
      houston(error)

      Async::HTTP::Protocol::Response.new(
        nil, 500, {}, Async::HTTP::Body::Buffered.wrap(
          StringIO.new("500 Low-Level Server Error")
        )
      )
    end

    private def load_apps_common
      application_path = File.join(config.root, "config/application.rb")

      if File.exist?(application_path)
        Kernel.load(application_path)
      end
    end

    # Returns applications eligble to be mounted, based on `config.mounts`.
    #
    private def mounts
      @__mounts.keep_if { |app|
        config.mounts == :all || config.mounts.include?(app.config.name)
      }
    end
  end

  include Behavior::Deprecations
  include Behavior::Multiapp
end
