package net.usebox.gemini.server

import java.nio.file.FileSystems

import scala.concurrent.duration._

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import akka.util.ByteString

class ServerSpec extends AnyFlatSpec with Matchers {

  def getPath(value: String) = FileSystems.getDefault().getPath(value)

  behavior of "validPath"

  it should "return true for the emtpy path" in {
    Server(TestData.conf).validPath("") shouldBe true
  }

  it should "return true for valid paths" in {
    List("/", "/file", "/./", "/.", "/dir/", "/dir/../").foreach { p =>
      Server(TestData.conf).validPath(p) shouldBe true
    }
  }

  it should "return false for invalid paths" in {
    List("/../", "/..", "/dir/../..", "/dir/../..", "/./../", "/./dir/.././../")
      .foreach { p =>
        Server(TestData.conf).validPath(p) shouldBe false
      }
  }

  behavior of "guessMimeType using the internal resolver"

  it should "resolve a known MIME type" in {
    Server(TestData.conf)
      .guessMimeType(
        getPath("file.html"),
        None
      ) shouldBe "text/html"
  }

  it should "resolve de default MIME type for unknown types" in {
    Server(TestData.conf)
      .guessMimeType(
        getPath("unknow"),
        None
      ) shouldBe TestData.conf.defaultMimeType
  }

  it should "resolve gemini MIME type" in {
    Server(TestData.conf)
      .guessMimeType(
        getPath("file.gmi"),
        None
      ) shouldBe "text/gemini"
    Server(TestData.conf)
      .guessMimeType(
        getPath("file.gemini"),
        None
      ) shouldBe "text/gemini"
  }

  it should "resolve gemini MIME type, including parameters" in {
    Server(TestData.conf)
      .guessMimeType(
        getPath("file.gmi"),
        Some("param")
      ) shouldBe "text/gemini; param"
    Server(TestData.conf)
      .guessMimeType(
        getPath("file.gemini"),
        Some("param")
      ) shouldBe "text/gemini; param"
  }

  it should "gemini MIME type parameters are sanitized" in {
    Server(TestData.conf)
      .guessMimeType(
        getPath("file.gmi"),
        Some("     ; param")
      ) shouldBe "text/gemini; param"
  }

  behavior of "guessMimeType using the configured types"

  it should "resolve a known MIME type" in {
    Server(TestData.conf.copy(mimeTypes = TestData.mimeTypes))
      .guessMimeType(
        getPath("file.gmi"),
        None
      ) shouldBe "config"
  }

  it should "include parameters for text/gemini MIME types" in {
    Server(
      TestData.conf.copy(mimeTypes = Some(Map("text/gemini" -> List(".gmi"))))
    ).guessMimeType(
      getPath("file.gmi"),
      Some("param")
    ) shouldBe "text/gemini; param"
  }

  it should "resolve de default MIME type for unknown types" in {
    Server(TestData.conf.copy(mimeTypes = TestData.mimeTypes))
      .guessMimeType(
        getPath("unknow"),
        None
      ) shouldBe TestData.conf.defaultMimeType
  }

  behavior of "decodeUTF8"

  it should "return right on valid UTF-8 codes" in {
    Server(TestData.conf)
      .decodeUTF8(ByteString("vĂ¡lid UTF-8")) shouldBe Symbol(
      "right"
    )
  }

  it should "return left on invalid UTF-8 codes" in {
    Server(TestData.conf)
      .decodeUTF8(ByteString(Array(0xc3.toByte, 0x28.toByte))) shouldBe Symbol(
      "left"
    )
  }

  behavior of "handleReq"

  it should "return bad request on URLs with no scheme" in {
    Server(TestData.conf)
      .handleReq("//localhost/", "127.0.0.1") should be(a[BadRequest])
  }

  it should "return proxy request refused on port mismatch" in {
    Server(TestData.conf)
      .handleReq("gemini://localhost:8080/", "127.0.0.1") should be(
      a[ProxyRequestRefused]
    )
  }

  it should "return proxy request refused when port not provided and configured port is not default" in {
    Server(TestData.conf.copy(port = 8080))
      .handleReq("gemini://localhost/", "127.0.0.1") should be(
      a[ProxyRequestRefused]
    )
  }

  it should "return success when port is provided and matches configured port (not default)" in {
    Server(TestData.conf.copy(port = 8080))
      .handleReq("gemini://localhost:8080/", "127.0.0.1") should be(a[Success])
  }

  it should "handle host case insensitive" in {
    Server(TestData.conf)
      .handleReq("gemini://LOCALHOST/", "127.0.0.1") should be(a[Success])
  }

  it should "return proxy request refused when the vhost is not found" in {
    Server(TestData.conf)
      .handleReq("gemini://otherhost/", "127.0.0.1") should be(
      a[ProxyRequestRefused]
    )
  }

  it should "return bad request when user info is present" in {
    Server(TestData.conf)
      .handleReq("gemini://user@localhost/", "127.0.0.1") should be(
      a[BadRequest]
    )
  }

  it should "return bad request when the path is out of root dir" in {
    Server(TestData.conf)
      .handleReq("gemini://localhost/../../", "127.0.0.1") should be(
      a[BadRequest]
    )
  }

  it should "return bad request for invalid URLs" in {
    Server(TestData.conf)
      .handleReq(
        "gemini://localhost/ invalid",
        "127.0.0.1"
      ) should be(a[BadRequest])
  }

  it should "redirect to normalize the URL" in {
    Server(TestData.conf)
      .handleReq("gemini://localhost/./", "127.0.0.1") should be(
      a[PermanentRedirect]
    )
  }

  it should "return not found if the path doesn't exist" in {
    Server(TestData.conf)
      .handleReq(
        "gemini://localhost/doesnotexist",
        "127.0.0.1"
      ) should be(a[NotFound])
  }

  it should "return not found if a dot file" in {
    Server(TestData.conf)
      .handleReq(
        "gemini://localhost/.dotfile",
        "127.0.0.1"
      ) should be(a[NotFound])
  }

  it should "return success on reading file" in {
    Server(TestData.conf)
      .handleReq(
        "gemini://localhost/index.gmi",
        "127.0.0.1"
      ) should matchPattern {
      case Success(_, "text/gemini", Some(_), 25L) =>
    }
  }

  it should "redirect and normalize request on a directory" in {
    Server(TestData.conf)
      .handleReq("gemini://localhost/dir", "127.0.0.1") should be(
      a[PermanentRedirect]
    )
  }

  it should "return an existing index file when requesting a directory" in {
    Server(TestData.conf)
      .handleReq("gemini://localhost/", "127.0.0.1") should matchPattern {
      case Success(_, "text/gemini", Some(_), 25L) =>
    }
  }

  it should "return proxy request refused for non gemini schemes" in {
    Server(TestData.conf)
      .handleReq("https://localhost/", "127.0.0.1") should be(
      a[ProxyRequestRefused]
    )
  }

  it should "include gemini params for gemini MIME type" in {
    Server(
      TestData.conf.copy(virtualHosts =
        List(TestData.conf.virtualHosts(0).copy(geminiParams = Some("test")))
      )
    ).handleReq(
      "gemini://localhost/index.gmi",
      "127.0.0.1"
    ) should matchPattern {
      case Success(_, "text/gemini; test", Some(_), 25L) =>
    }
  }

  behavior of "handleReq, directory listings"

  it should "return a directory listing if is enabled and no index" in {
    Server(TestData.conf)
      .handleReq("gemini://localhost/dir/", "127.0.0.1") should be(
      a[DirListing]
    )
  }

  it should "return a directory listing, directory listing flags: vhost flag false, directories flag true" in {
    Server(
      ServiceConf.initConf(
        TestData.conf.copy(virtualHosts =
          List(
            TestData.conf
              .virtualHosts(0)
              .copy(
                directoryListing = false,
                directories = List(
                  Directory(
                    "dir/",
                    directoryListing = Some(true),
                    allowCgi = None
                  )
                )
              )
          )
        )
      )
    ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[DirListing])
  }

  it should "return not found with no index, directory listing flags: vhost flag true, directories flag false" in {
    Server(
      ServiceConf.initConf(
        TestData.conf.copy(virtualHosts =
          List(
            TestData.conf
              .virtualHosts(0)
              .copy(
                directoryListing = true,
                directories = List(
                  Directory(
                    "dir/",
                    directoryListing = Some(false),
                    allowCgi = None
                  )
                )
              )
          )
        )
      )
    ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound])
  }

  it should "not apply directory listing override to subdirectories" in {
    Server(
      ServiceConf.initConf(
        TestData.conf.copy(virtualHosts =
          List(
            TestData.conf
              .virtualHosts(0)
              .copy(
                directoryListing = false,
                directories = List(
                  Directory(
                    "dir/",
                    directoryListing = Some(true),
                    allowCgi = None
                  )
                )
              )
          )
        )
      )
    ).handleReq("gemini://localhost/dir/sub/", "127.0.0.1") should be(
      a[NotFound]
    )
  }

  it should "return not found if directory listing is not enabled and no index" in {
    Server(
      TestData.conf.copy(virtualHosts =
        List(TestData.conf.virtualHosts(0).copy(directoryListing = false))
      )
    ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound])
  }

  behavior of "handleReq, user directories"

  it should "return success on reading file" in {
    Server(TestData.confUserDir).handleReq(
      "gemini://localhost/~username/index.gmi",
      "127.0.0.1"
    ) should matchPattern {
      case Success(_, "text/gemini", Some(_), 38L) =>
    }
  }

  it should "return redirect accessing the user directory without ending slash" in {
    Server(TestData.confUserDir).handleReq(
      "gemini://localhost/~username",
      "127.0.0.1"
    ) should be(a[PermanentRedirect])
  }

  it should "return success accessing the user directory index" in {
    Server(TestData.confUserDir).handleReq(
      "gemini://localhost/~username/",
      "127.0.0.1"
    ) should matchPattern {
      case Success(_, "text/gemini", Some(_), 38L) =>
    }
  }

  it should "return bad request trying to exit the root directory" in {
    Server(TestData.confUserDir).handleReq(
      "gemini://localhost/~username/../../",
      "127.0.0.1"
    ) should be(a[BadRequest])
  }

  it should "return redirect to the virtual host root when leaving the user dir" in {
    Server(TestData.confUserDir).handleReq(
      "gemini://localhost/~username/../",
      "127.0.0.1"
    ) should be(a[PermanentRedirect])
  }

  it should "not translate root if used an invalid user pattern" in {
    Server(TestData.confUserDir).handleReq(
      "gemini://localhost/~username../",
      "127.0.0.1"
    ) should be(a[NotFound])

    Server(TestData.confUserDir).handleReq(
      "gemini://localhost/~0invalid/",
      "127.0.0.1"
    ) should be(a[NotFound])
  }

  it should "not translate root if no user directory path was provided" in {
    Server(
      TestData.conf.copy(virtualHosts =
        List(
          TestData.conf
            .virtualHosts(0)
            .copy(userDirectories = true)
        )
      )
    ).handleReq(
      "gemini://localhost/~username/",
      "127.0.0.1"
    ) should be(a[NotFound])
  }

  it should "not execute a CGI if the target resource is not executable" in {
    Server(TestData.cgiConf).handleReq(
      "gemini://localhost/dir/file.txt",
      "127.0.0.1"
    ) should matchPattern {
      case Success(_, "text/plain", Some(_), 5L) =>
    }
  }

  it should "not execute a CGI if the target resource is a directory" in {
    Server(TestData.cgiConf).handleReq(
      "gemini://localhost/dir/sub/",
      "127.0.0.1"
    ) should be(a[DirListing])
  }

  it should "not apply allow CGI to subdirectories" in {
    Server(TestData.cgiConf).handleReq(
      "gemini://localhost/dir/sub/cgi",
      "127.0.0.1"
    ) should matchPattern {
      case Success(_, "text/plain", Some(_), 72) =>
    }
  }

  it should "execute a CGI" in {
    val cgi = Server(TestData.cgiConf)
      .handleReq(
        "gemini://localhost/dir/cgi",
        "127.0.0.1"
      )
      .asInstanceOf[Cgi]

    cgi.status should be(20)
    cgi.meta should be("text/gemini")
    cgi.body should include("GATEWAY_INTERFACE=CGI/1.1")
  }

  it should "execute a CGI: empty parameters, host and port" in {
    Server(TestData.cgiConf).handleReq(
      "gemini://localhost/dir/cgi",
      "127.0.0.1"
    ) should matchPattern {
      case Cgi(
            _,
            _,
            "",
            "",
            "cgi",
            TestData.host,
            TestData.portStr,
            _,
            _
          ) =>
    }
  }

  it should "execute a CGI: host is case-insensitive and the value in the conf is used" in {
    Server(TestData.cgiConf).handleReq(
      "gemini://LOCALHOST/dir/cgi",
      "127.0.0.1"
    ) should matchPattern {
      case Cgi(
            _,
            _,
            "",
            "",
            "cgi",
            TestData.host,
            TestData.portStr,
            _,
            _
          ) =>
    }
  }

  it should "execute a CGI: query string" in {
    Server(TestData.cgiConf).handleReq(
      "gemini://localhost/dir/cgi?query&string",
      "127.0.0.1"
    ) should matchPattern {
      case Cgi(
            _,
            _,
            "query&string",
            "",
            "cgi",
            TestData.host,
            TestData.portStr,
            _,
            _
          ) =>
    }
  }

  it should "execute a CGI: path info" in {
    Server(TestData.cgiConf).handleReq(
      "gemini://localhost/dir/cgi/path/info",
      "127.0.0.1"
    ) should matchPattern {
      case Cgi(
            _,
            _,
            "",
            "/path/info",
            "cgi",
            TestData.host,
            TestData.portStr,
            _,
            _
          ) =>
    }
  }

  it should "execute a CGI: query string and path info" in {
    Server(TestData.cgiConf).handleReq(
      "gemini://localhost/dir/cgi/path/info?query=string",
      "127.0.0.1"
    ) should matchPattern {
      case Cgi(
            _,
            _,
            "query=string",
            "/path/info",
            "cgi",
            TestData.host,
            TestData.portStr,
            _,
            _
          ) =>
    }
  }

  it should "not execute an executable if allow CGI is off" in {
    Server(TestData.conf)
      .handleReq(
        "gemini://localhost/dir/cgi",
        "127.0.0.1"
      ) should matchPattern {
      case Success(_, "text/plain", Some(_), _) =>
    }
  }

  it should "response with an error if the CGI exits with non 0" in {
    val bad = Server(TestData.cgiConf)
      .handleReq(
        "gemini://localhost/dir/bad-cgi",
        "127.0.0.1"
      )
      .asInstanceOf[Cgi]

    val meta = "Error executing CGI"
    bad.status should be(50)
    bad.meta should be(meta)
    bad.body should include(meta)
  }

  it should "return a response with an error if the CGI exits with non 0" in {
    val bad = Server(TestData.cgiConf)
      .handleReq(
        "gemini://localhost/dir/bad-cgi",
        "127.0.0.1"
      )
      .asInstanceOf[Cgi]

    val meta = "Error executing CGI"
    bad.status should be(50)
    bad.meta should be(meta)
    bad.body should include(meta)
  }

  it should "return a response with an error if the CGI response is invalid" in {
    val bad = Server(TestData.cgiConf)
      .handleReq(
        "gemini://localhost/dir/bad-response",
        "127.0.0.1"
      )
      .asInstanceOf[Cgi]

    val meta = "Invalid response from CGI"
    bad.status should be(40)
    bad.meta should be(meta)
    bad.body should include(meta)
  }

  it should "environment variables are optional" in {
    Server(TestData.cgiConf).handleReq(
      "gemini://localhost/dir/cgi/",
      "127.0.0.1"
    ) should matchPattern {
      case Cgi(
            _,
            _,
            _,
            _,
            "cgi",
            TestData.host,
            TestData.portStr,
            _,
            m
          ) if m == Map() =>
    }
  }

  it should "pass environment variables to the CGI" in {
    Server(TestData.cgiEnvConf).handleReq(
      "gemini://localhost/dir/cgi/",
      "127.0.0.1"
    ) should matchPattern {
      case Cgi(
            _,
            _,
            _,
            _,
            "cgi",
            TestData.host,
            TestData.portStr,
            _,
            m
          ) if m == Map("env1" -> "value") =>
    }
  }

  it should "execute a CGI with the environment variables" in {
    val cgi = Server(TestData.cgiEnvConf)
      .handleReq(
        "gemini://localhost/dir/cgi",
        "127.0.0.1"
      )
      .asInstanceOf[Cgi]

    cgi.status should be(20)
    cgi.meta should be("text/gemini")
    cgi.body should include("env1=value")
  }

  it should "execute a CGI when it is the index document" in {
    val cgi = Server(TestData.cgiIndexConf)
      .handleReq(
        "gemini://localhost/dir/",
        "127.0.0.1"
      )
      .asInstanceOf[Cgi]

    cgi.status should be(20)
    cgi.meta should be("text/gemini")
    cgi.body should include("GATEWAY_INTERFACE=CGI/1.1")
  }

  it should "execute a CGI when it is the index document (full name)" in {
    val cgi = Server(TestData.cgiIndexConf)
      .handleReq(
        "gemini://localhost/dir/cgi",
        "127.0.0.1"
      )
      .asInstanceOf[Cgi]

    cgi.status should be(20)
    cgi.meta should be("text/gemini")
    cgi.body should include("GATEWAY_INTERFACE=CGI/1.1")
  }

  it should "execute a CGI when it is the index document (full name, path info)" in {
    val cgi = Server(TestData.cgiIndexConf)
      .handleReq(
        "gemini://localhost/dir/cgi/path/info",
        "127.0.0.1"
      )
      .asInstanceOf[Cgi]

    cgi.status should be(20)
    cgi.meta should be("text/gemini")
    cgi.body should include("GATEWAY_INTERFACE=CGI/1.1")
    cgi.body should include("PATH_INFO=/path/info")
  }

  object TestData {

    val host = "localhost"
    val port = 1965
    val portStr = port.toString()

    val conf = ServiceConf(
      address = "127.0.0.1",
      port = port,
      defaultMimeType = "text/plain",
      idleTimeout = 10.seconds,
      virtualHosts = List(
        VirtualHost(
          host = host,
          root = getClass.getResource("/").getPath()
        )
      ),
      genCertValidFor = 1.day,
      enabledProtocols = Nil,
      enabledCipherSuites = Nil
    )

    val cgiConf = ServiceConf.initConf(
      conf.copy(virtualHosts =
        List(
          conf
            .virtualHosts(0)
            .copy(
              directoryListing = true,
              directories = List(
                Directory(
                  "dir/",
                  directoryListing = Some(false),
                  allowCgi = Some(true)
                )
              )
            )
        )
      )
    )

    val cgiEnvConf = cgiConf.copy(virtualHosts =
      List(
        cgiConf
          .virtualHosts(0)
          .copy(
            environment = Some(Map("env1" -> "value"))
          )
      )
    )

    val cgiIndexConf = cgiConf.copy(virtualHosts =
      List(
        cgiConf
          .virtualHosts(0)
          .copy(
            indexFile = "cgi"
          )
      )
    )

    val confUserDir = conf.copy(virtualHosts =
      List(
        conf
          .virtualHosts(0)
          .copy(
            userDirectories = true,
            userDirectoryPath = Some(
              getClass.getResource("/").getPath() + "{user}/public_gemini/"
            )
          )
      )
    )

    val mimeTypes = Some(
      Map(
        "config" -> List(".gmi", ".gemini")
      )
    )
  }
}