#### Aya - A Gemini server
#### Copyright (C) 2021 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify
#### it under the terms of the GNU Affero General Public License as
#### published by the Free Software Foundation, either version 3 of the
#### License, or (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#### GNU Affero General Public License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program.  If not, see <https://www.gnu.org/licenses/>.
require "openssl"
require "socket"
require "uri"

require "./socket-extensions"
require "./support"

module Aya
  SHOW_DIRECTORY_FILE = ".showdir"

  class Listener
    def initialize(@host : String, @port : UInt16 = 1965u16)
      RemiLog.log.dlog("Init listener: #{@host}:#{@port}")
    end

    private def transferFile(sock, mtype : String, path : Path)
      File.open(path) do |infile|
        if Aya.config.rateLimit > 0 && (Aya.config.rateLimitType.all? || mtype != GEMINI_MIME_TYPE)
          RemiLog.log.dlog("Rate limiting transfer of #{path}")
          sock.rateLimitedCopy(Aya.config.rateLimit * 1024, infile)
        else
          RemiLog.log.dlog("Not rate limiting transfer of #{path}")
          IO.copy(infile, sock)
        end
      end

      if mtype == GEMINI_MIME_TYPE && !Aya.config.footer.empty?
        sock << '\n' << Aya.config.footer << '\n'
      end
    end

    private def validURI?(uri : URI) : Bool
      return false unless uri.scheme == "gemini"
      return false unless uri.host
      return false if uri.userinfo
      true
    end

    private def serveDirectory(sock, uri, path)
      RemiLog.log.dlog("Serving directory: #{path}")
      uriPath = Path[uri.path]

      gemtext = String.build do |str|
        str << "# Listing of #{uri}\n\n"

        if inServerRoot?(path.parent) && File.exists?(Dir[path.parent].join(SHOW_DIRECTORY_FILE))
          str << "=> #{uriPath.parent} (up one directory)\n"
        end

        Dir.each_child(path) do |child|
          fullpath = path.join(child)
          unless File.info(fullpath).symlink? || fullpath.basename == SHOW_DIRECTORY_FILE
            str << "=> #{uriPath.join(child)} #{child}\n"
          end
        end
      end

      sock << "20 #{GEMINI_MIME_TYPE}\r\n"
      sock << gemtext
      return
    end

    private def inServerRoot?(path : Path) : Bool
      begin
        otherParts = path.parts
        Path[Aya.config.serverRoot].parts.each_with_index do |part, idx|
          return false unless otherParts[idx] == part
        end
      rescue Exception
        return false
      end

      true
    end

    private def handleConnection(sock)
      begin
        # Get and validate the requested URI
        uri = URI.parse(sock.gets || return)
        unless validURI?(uri)
          RemiLog.log.error("Unsupported URI: #{uri}")
          sock << "59 Unsupported URI\r\n"
          return
        end

        # Convert the URI's path into a Path instance
        path = Path[Aya.config.serverRoot].join(uri.path).normalize

        # Path must be inside the server root
        unless inServerRoot?(path)
          RemiLog.log.warn("Path is outside server root: #{path}")
          sock << "51 Not found: #{uri.path}\r\n"
          return
        end

        if Dir.exists?(path)
          # Path must be inside the server root

          if File.exists?(path.join(SHOW_DIRECTORY_FILE))
            # Generate a dynamic directory listing
            serveDirectory(sock, uri, path)
            return
          else
            # Append index.gmi to the path
            path = path.join("index.gmi")
          end
        elsif path.basename == SHOW_DIRECTORY_FILE
          # Never serve .showdir files
          sock << "51 Not found: #{uri.path}\r\n"
          return
        else
          # Requested file must exist
          unless File.exists?(path)
            sock << "51 Not found: #{uri.path}\r\n"
            return
          end
        end

        RemiLog.log.dlog("Request: #{uri}")

        # Requested file must exist
        unless File.exists?(path)
          sock << "51 Not found: #{uri.path}\r\n"
          return
        end

        # Handle symlinks
        if File.info(path).symlink?
          if Aya.config.followSymlinks.none?
            sock << "51 Not found\r\n"
            RemiLog.log.error("File is a symlink: #{path}")
            return
          end

          # TODO
          # when .root_only?
          #   realPath = Path[File.real_path(path)]
          #   unless realPath.parent == Path[Aya.config.serverRoot].parent
          #     sock << "40 Not found\r\n"
          #     RemiLog.log.error("File is a symlink that leads out of the root: #{path}")
          #     return
          #   end
        end

        path.each_parent do |parent|
          if File.info(parent).symlink?
            if Aya.config.followSymlinks.none?
              sock << "51 Not found\r\n"
              RemiLog.log.error("File is a symlink: #{path}")
              return
            end

            # TODO handle .root_only?
          end
        end

        # CGI script?  Handle that separately.
        if Aya.config.cgiExtensions.includes?(path.extension)
          handleCGI(sock, uri, path)
          return
        end

        # Serve the request
        begin
          RemiLog.log.dlog("Returning #{path}")
          mtype = MIME.from_filename?(path)
          unless mtype
            if Aya.config.unknownAsOctetStream
              mtype = "application/octet-stream"
            else
              sock << "40 Internal error\r\n"
              RemiLog.log.error("Cannot serve file, unknown MIME type: #{path}")
              return
            end
          end

          sock << "20 #{mtype.not_nil!}\r\n"
          transferFile(sock, mtype, path)
        rescue err : File::NotFoundError
          sock << "51 Not found: #{uri.path}\r\n"
          return
        end
      rescue err : Socket::Error
        sock << "40 Internal error\r\n"
        RemiLog.log.error("#{err}")
      rescue err : OpenSSL::Error
        sock << "40 Internal error\r\n"
        RemiLog.log.error("#{err}")
      rescue err : OpenSSL::SSL::Error
        sock << "40 Internal error\r\n"
        RemiLog.log.error("#{err}")
      rescue err : Exception
        sock << "40 Internal error\r\n"
        RemiLog.log.error("#{typeof(err)} while attempting to serve #{uri}")
        RemiLog.log.error(err)
      ensure
        sock.close
      end
    end

    private def handleCGI(sock, uri, path)
      RemiLog.log.dlog("Serving CGI request: #{uri}")
      RemiLog.log.dlog("CGI path for #{uri}: #{path}")

      # Setup the environment we'll use for the process.
      env = {} of String => String
      env["DOCUMENT_ROOT"] = Aya.config.serverRoot
      env["GATEWAY_INTERFACE"] = "ACGI 0.1.0" # "Aya CGI" with symver
      env["PATH_INFO"] = uri.path || ""
      env["PATH_TRANSLATED"] = path.to_s
      env["SCRIPT_FILENAME"] = path.basename.to_s
      env["QUERY_STRING"] = uri.query || ""
      env["REQUEST_FRAGMENT"] = uri.fragment || ""
      env["SERVER_HOST"] = uri.host || ""
      env["SERVER_PORT"] = uri.port.try &.to_s || Aya.config.listenPort.to_s
      env["PATH"] = Aya.config.cgiPath

      begin
        # Start the process and connect I/O
        proc = Process.new(path.to_s, env: env, clear_env: true,
                           input: Process::Redirect::Pipe, output: Process::Redirect::Pipe,
                           error: Process::Redirect::Pipe)
        # HACK This will easily eat ram.
        result = proc.output.gets_to_end
        error = proc.error.gets_to_end

        # Wait for the process to finish, then send a response.
        status = proc.wait
        if status.success?
          sock << result
        else
          RemiLog.log.error("CGI script #{path} exited with status #{status.exit_code}")
          sock << "40 Internal Error\r\n"
        end

        # If we had output on stderr, log that now.
        unless error.empty?
          RemiLog.log.error("Error while executing #{path}: #{error}")
        end
      rescue err : IO::Error
        # Process failed to start! D:
        RemiLog.log.error("Error executing #{path}: #{err}")
        sock << "40 Internal error\r\n"
      end
    end

    def run
      begin
        # Create and bind the socket
        listen = TCPServer.new(@host, @port)

        # Create SSL context
        ctx = OpenSSL::SSL::Context::Server.new
        ctx.private_key = Aya.config.tlsKey
        ctx.certificate_chain = Aya.config.certChain
        ctx.add_options(OpenSSL::SSL::Options::NO_SSL_V2 |
                        OpenSSL::SSL::Options::NO_SSL_V3 |
                        OpenSSL::SSL::Options::NO_TLS_V1 |
                        OpenSSL::SSL::Options::NO_TLS_V1_1 |
                        OpenSSL::SSL::Options::ALL)

        # If @host is an IP address, check to see that it's a known address.
        # TODO this seems to fail with IPv6
        begin
          addr = Socket::IPAddress.new(@host, @port.to_i32)
          unless Aya::Support.getAddresses(listen).includes?(addr.address)
            RemiLog.log.fatal("Cannot listen on #{@host}")
          end
        rescue Socket::Error
          # @host is not an IP address
        end

        # Start listening
        RemiLog.log.log("Listening on #{@host}:#{@port}")
        loop do
          begin
            client = listen.accept
            sslConn = OpenSSL::SSL::Socket::Server.new(client, ctx)

            Aya.connLogger.try &.log("New connection from #{client.remote_address}")
            spawn do
              RemiLog.log.vlog("Handling new connection! :D")
              handleConnection(sslConn)
            end
          rescue err : Socket::Error
            RemiLog.log.error("#{err}")
          rescue err : OpenSSL::Error
            RemiLog.log.error("#{err}")
          rescue err : OpenSSL::SSL::Error
            RemiLog.log.error("#{err}")
          rescue err : Exception
            RemiLog.log.error(err)
          end
        end
      rescue err : Socket::BindError
        RemiLog.log.fatal("Cannot bind socket: #{err}")
      end
    end
  end
end
