/*
 * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package zio.http

import java.util.UUID

import zio.Chunk
import zio.test.Assertion._
import zio.test._

import zio.http.internal.HttpGen

object URLSpec extends ZIOHttpSpec {
  def extractPath(url: URL): Path = url.path
  def asURL(string: String): URL  = URL.decode(string).toOption.get

  def spec =
    suite("URL")(
      test("empty") {
        check(HttpGen.url) { url =>
          assertTrue(
            url == url ++ URL.empty,
            url == URL.empty ++ url,
          )
        }
      },
      suite("equality/hash")(
        test("leading slash invariant") {
          check(HttpGen.url) { url =>
            val url1 = url.copy(path = extractPath(url).dropLeadingSlash)
            val url2 = url.copy(path = extractPath(url).addLeadingSlash)

            assertTrue(url1 == url2, url1.hashCode == url2.hashCode)
          }
        },
      ),
      suite("normalize")(
        test("adds leading slash") {
          val url = URL(Path("a/b/c"), URL.Location.Absolute(Scheme.HTTP, "abc.com", Some(80)), QueryParams.empty, None)

          val url2 = url.normalize

          assertTrue(url2.encode == "http://abc.com:80/a/b/c", extractPath(url2) == Path("/a/b/c"))
        },
        test("deletes leading slash if there are no path segments") {
          val url  = URL(Path.root, URL.Location.Absolute(Scheme.HTTP, "abc.com", Some(80)), QueryParams.empty, None)
          val url2 = url.normalize

          assertTrue(extractPath(url2) == Path.empty)
        },
        test("add leading slash to absolute URLs without port") {
          val url  = URL(Path("a/b/c"), URL.Location.Absolute(Scheme.HTTP, "abc.com", None), QueryParams.empty, None)
          val url2 = url.normalize
          val urlInterpolator         = url"https://abc.com/a/b/c"
          val urlInterpolator2        = urlInterpolator.normalize
          val baseUrlInterpolator     = url"https://abc.com"
          val extendedUrlInterpolator = (baseUrlInterpolator / "a" / "b" / "c").normalize

          assertTrue(
            url2.encode == "http://abc.com/a/b/c",
            extractPath(url2) == Path("/a/b/c"),
            urlInterpolator2.encode == "https://abc.com/a/b/c",
            extractPath(urlInterpolator2) == Path("/a/b/c"),
            extendedUrlInterpolator.encode == "https://abc.com/a/b/c",
            extractPath(extendedUrlInterpolator) == Path("/a/b/c"),
          )
        },
      ),
      suite("encode-decode symmetry")(
        test("url interpolator") {
          val url           = url"https://api.com:8080/users?x=10&y=20"
          val urlSpaces     = url"https://api.com:8080/users?x=10&y=20&spaces=with%20spaces"
          val urlSpacesPlus = url"https://api.com:8080/users?x=10&y=20&spaces=with+spaces"
          assertTrue(
            url.encode == "https://api.com:8080/users?x=10&y=20",
            urlSpaces.encode == "https://api.com:8080/users?x=10&y=20&spaces=with+spaces",
            urlSpacesPlus.encode == "https://api.com:8080/users?x=10&y=20&spaces=with+spaces",
            URL.decode(url.encode) == Right(url),
            URL.decode(urlSpaces.encode) == Right(urlSpaces),
            URL.decode(urlSpacesPlus.encode) == Right(urlSpacesPlus),
          )
        },
        test("url with space in path") {
          val urlWithSpace = URL(Path("/my folder/file.txt"))
          val expected     = urlWithSpace.encode
          assertTrue(expected == "/my%20folder/file.txt")
        },
        test("url with already encoded path should not double encode") {
          val originalUrl = "http://testsample.com/file/test%20hotel.pdf"
          val decoded     = URL.decode(originalUrl)
          val reEncoded   = decoded.map(_.encode)
          assertTrue(reEncoded == Right(originalUrl))
        },
        test("url fromURI should not double encode path") {
          val uri = new java.net.URI("http://testsample.com/file/test%20hotel.pdf")
          val url = URL.fromURI(uri)
          assertTrue(
            url.isDefined,
            url.get.encode == "http://testsample.com/file/test%20hotel.pdf",
          )
        },
        test("auto-gen") {
          check(HttpGen.url) { url =>
            val expected        = url.copy(path = url.path.addLeadingSlash)
            val expectedEncoded = expected.encode
            val actual          = URL.decode(expected.encode)
            val actualEncoded   = actual.map(_.encode)

            assertTrue(asURL(actualEncoded.toOption.get) == asURL(expectedEncoded), actual.toOption.get == expected)
          }
        },
        test("manual") {
          val urls = Gen.fromIterable(
            Seq(
              "",
              "/",
              "/users?ord=ASC&txt=scala%20is%20awesome%21&u=1&u=2",
              "/users",
              "/users#the%20hash",
              "http://abc.com",
              "http://abc.com/",
              "http://abc.com/list",
              "http://abc.com/users?ord=ASC&txt=scala%20is%20awesome%21&u=1&u=2",
              "http://abc.com/users?u=1&u=2&ord=ASC&txt=scala%20is%20awesome%21",
              "http://abc.com/users?u=1#the%20hash",
              "http://abc.com/users",
              "http://abc.com/users/?u=1&u=2&ord=ASC&txt=scala%20is%20awesome%21",
              "http://abc.com/users#the%20hash",
              "ws://abc.com/subscriptions",
              "wss://abc.com/subscriptions",
            ),
          )

          checkAll(urls) { url =>
            val decoded = URL.decode(url)
            val encoded = decoded.map(_.encode)
            assertTrue(encoded == Right(url) || encoded == Right(url.replaceAll("%20", "+")))
          }
        },
      ),
      suite("fromString")(
        test("should Handle invalid url String with restricted chars") {
          val actual = URL.decode("http://mw1.google.com/$[level]/r$[y]_c$[x].jpg")
          assert(actual)(isLeft)
        },
      ),
      suite("relative")(
        test("converts an url to a relative url") {
          val actual   = URL.decode("http://abc.com/users?a=1&b=2").map(_.relative.encode)
          val expected = Right("/users?a=1&b=2")
          assertTrue(actual == expected)
        },
      ),
      suite("path")(
        test("updates the path without needed to know the host") {
          val host     = "http://abc.com"
          val channels = "/channels"
          val users    = "/users"
          val actual   = URL.decode(host + users).map(_.path(channels).encode)
          val expected = Right(host + channels)
          assertTrue(actual == expected)
        },
        test("does not escape valid characters") {
          check(HttpGen.nonEmptyPath) { path =>
            val urlEncode  = URL(path = path).encode
            val pathEncode = path.encode
            assertTrue(urlEncode == pathEncode)
          }
        },
      ),
      suite("builder")(
        test("creates a URL with all attributes set") {
          val builderUrl = URL.empty
            .host("www.abc.com")
            .path("/list")
            .port(8080)
            .scheme(Scheme.HTTPS)
            .setQueryParams("?type=builder&query=provided")

          assertTrue(builderUrl == asURL("https://www.abc.com:8080/list?type=builder&query=provided"))
        },
        test("returns relative URL if port, host, and scheme are not set") {
          val actual = URL.empty
            .path(Path.decode("/list"))
            .setQueryParams(QueryParams("query" -> Chunk("provided"), "type" -> Chunk("builder")))
            .encode

          assertTrue(asURL(actual) == asURL("/list?query=provided&type=builder"))
        },
      ),
      suite("java interop")(
        test("can not create a java.net.URL from a relative URL") {
          check(HttpGen.genRelativeURL) { url =>
            assert(url.toJavaURL)(isNone)
          }
        },
        test("converts a zio.http.URL to java.net.URI") {
          check(HttpGen.genAbsoluteURL) { url =>
            val httpURLString = url.encode
            val javaURLString = url.toJavaURI.toString
            assertTrue(httpURLString == javaURLString)
          }
        },
      ),
      suite("hostPort")(
        test("does not add the port 80 for http") {
          assertTrue(
            URL.decode("http://localhost:80").toOption.flatMap(_.hostPort) == Some("localhost"),
          )
        },
        test("adds the port 8080 for http") {
          assertTrue(
            URL.decode("http://localhost:8080").toOption.flatMap(_.hostPort) == Some("localhost:8080"),
          )
        },
        test("does not add the port 443 for https") {
          assertTrue(
            URL.decode("https://localhost:443").toOption.flatMap(_.hostPort) == Some("localhost"),
          )
        },
        test("adds the port 80 for https") {
          assertTrue(
            URL.decode("https://localhost:80").toOption.flatMap(_.hostPort) == Some("localhost:80"),
          )
        },
      ),
      suite("string interpolator")(
        test("valid static absolute url") {
          val url = url"https://api.com:8080/users?x=10&y=20"
          assertTrue(
            url == URL.decode("https://api.com:8080/users?x=10&y=20").toOption.get,
          )
        },
        test("valid static relative url") {
          val url = url"/users?x=10&y=20"
          assertTrue(
            url == URL.decode("/users?x=10&y=20").toOption.get,
          )
        },
        test("invalid url") {
          val result = typeCheck {
            """val url: URL = url"http:/x/y/z"
            """
          }
          assertZIO(result)(isLeft)
        },
        test("dynamic absolute url") {
          val host   = "localhost"
          val port   = 8080
          val entity = "users"
          val url    = url"http://$host:$port/$entity/get"
          assertTrue(
            url == URL.decode(s"http://localhost:8080/users/get").toOption.get,
          )
        },
        test("dynamic relative url") {
          val entity = "users"
          val uuid   = UUID.fromString("1E7E4039-66AE-4CFA-A493-0AC0FC0AD45B")
          val bool   = false
          val url    = url"$entity/$uuid/get?valid=$bool"
          assertTrue(
            url == URL.decode(s"users/1e7e4039-66ae-4cfa-a493-0ac0fc0ad45b/get?valid=false").toOption.get,
          )
        },
        test("dynamic invalid url") {
          val result = typeCheck {
            """val a = "hello"
               val b = 10
               val url: URL = url"http:/$a:$b/y/z"
            """
          }
          assertZIO(result)(isLeft)
        },
        test("dynamic invalid url 2") {
          val result = typeCheck {
            """val a = "hello"
               val b = false
               val url: URL = url"http://$a:$b/y/z"
            """
          }
          assertZIO(result)(isLeft)
        },
      ),
      suite("query parameter encoding")(
        test("colons in query param values should be percent-encoded") {
          val url = URL.root.addQueryParam("pe", "http://example.com")
          assertTrue(url.encode == "/?pe=http%3A%2F%2Fexample.com")
        },
        test("at-signs in query param values should be percent-encoded") {
          val url = URL.root.addQueryParam("email", "user@example.com")
          assertTrue(url.encode == "/?email=user%40example.com")
        },
        test("colons in path segments should NOT be percent-encoded") {
          val url = URL(Path("/v1:validateAddress"))
          assertTrue(url.encode == "/v1:validateAddress")
        },
        test("at-signs in path segments should NOT be percent-encoded") {
          val url = URL(Path("/users/@me"))
          assertTrue(url.encode == "/users/@me")
        },
      ),
      suite("relative resolution")(
        // next ones are edge cases
        test("absolute reference with relative base") {
          val base      = url"base/relative#basefrag"
          val reference = url"https://reference/ignored/.././absolute#reffrag"

          // uses reference without dot segments
          val expected = url"https://reference/absolute#reffrag"

          val result = base.resolve(reference)
          assertTrue(result.contains(expected))
        },
        test("absolute reference with absolute base") {
          val base      = url"https://base#basefrag"
          val reference = url"https://reference/ignored/.././absolute#reffrag"

          // uses reference without dot segments
          val expected = url"https://reference/absolute#reffrag"

          val result = base.resolve(reference)
          assertTrue(result.contains(expected))
        },
        test("relative reference with relative base") {
          val base      = url"base/relative"
          val reference = url"reference/relative"

          val result = base.resolve(reference)
          assertTrue(result.isLeft)
        },

        // remainder are main resolution logic - absolute base, relative reference
        test("empty reference path without query params") {
          val base      = url"https://base/./ignored/../absolute?param=base#basefrag"
          val reference = url"#reffrag"

          // uses unmodified base path and base query params
          val expected = url"https://base/./ignored/../absolute?param=base#reffrag"

          val result = base.resolve(reference).toOption.get
          assertTrue(result == expected)
        },
        test("empty reference path with query params") {
          val base      = url"https://base/./ignored/../absolute?param=base#basefrag"
          val reference = url"?param=reference#reffrag"

          // uses unmodified base path and reference query params
          val expected = url"https://base/./ignored/../absolute?param=reference#reffrag"

          val result = base.resolve(reference).toOption.get
          assertTrue(result == expected)
        },
        test("non-empty reference path with a leading slash") {
          val base      = url"https://base/./ignored/../first/second?param=base#basefrag"
          val reference = url"/reference/./ignored/../last?param=reference#reffrag"

          // uses reference path without dot segments and reference query params
          val expected = url"https://base/reference/last?param=reference#reffrag"

          val result = base.resolve(reference)
          assertTrue(result.contains(expected))
        },
        test("non empty reference path without a leading slash") {
          val base      = url"https://base/./ignored/../first/..?param=base#basefrag"
          val reference = url"reference/./ignored/../last?param=reference#reffrag"

          // uses base path without last segment, reference segments appended, without dot segments, and reference query params
          val expected = url"https://base/reference/last?param=reference#reffrag"

          val result = base.resolve(reference).toOption.get
          assertTrue(result == expected)
        },
        test("non-empty reference path without a leading slash and empty base path") {
          val base      = url"https://base?param=base#basefrag"
          val reference = url"reference/./ignored/../last?param=reference#reffrag"

          // uses reference path without dot segments and a leading slash
          val expected = url"https://base/reference/last?param=reference#reffrag"

          val result = base.resolve(reference)
          assertTrue(result.contains(expected))
        },
      ),
      suite("decodeOrNull")(
        test("valid plain path returns non-null URL") {
          val result = URL.decodeOrNull("/plaintext")
          assertTrue(
            result ne null,
            result.path == Path("/plaintext"),
          )
        },
        test("absolute URL with braces in query returns non-null URL") {
          val result = URL.decodeOrNull("https://example.com/t?v={bla}")
          assertTrue(
            result ne null,
            result.kind == URL.Location.Absolute(Scheme.HTTPS, "example.com", None),
            result.path == Path("/t"),
            result.queryParams.queryParam("v") == Some("{bla}"),
          )
        },
        test("relative URL with braces in query returns non-null URL") {
          val result = URL.decodeOrNull("/t?v={bla}")
          assertTrue(
            result ne null,
            result.kind == URL.Location.Relative,
            result.path == Path("/t"),
            result.queryParams.queryParam("v") == Some("{bla}"),
          )
        },
        test("valid path with query params returns non-null URL") {
          val result = URL.decodeOrNull("/api/users?id=1")
          assertTrue(
            result ne null,
            result.path == Path("/api/users"),
          )
        },
        test("invalid URL returns null") {
          val result = URL.decodeOrNull("not valid %%%")
          assertTrue(result eq null)
        },
        test("valid absolute URL returns non-null URL") {
          val result = URL.decodeOrNull("http://example.com/path")
          assertTrue(
            result ne null,
            result.kind.isAbsolute,
          )
        },
      ),
      suite("relative URI fast path")(
        test("empty string returns URL.empty") {
          val url = URL.decode("").toOption.get
          assertTrue(url == URL.empty)
        },
        test("path only") {
          val url = URL.decode("/api/users/123").toOption.get
          assertTrue(
            url.path == Path.decode("/api/users/123"),
            url.queryParams.isEmpty,
            url.fragment.isEmpty,
            url.kind == URL.Location.Relative,
          )
        },
        test("path with query") {
          val url = URL.decode("/api/users?page=2&limit=10").toOption.get
          assertTrue(
            url.path == Path.decode("/api/users"),
            url.queryParams.queryParam("page") == Some("2"),
            url.queryParams.queryParam("limit") == Some("10"),
            url.fragment.isEmpty,
          )
        },
        test("path with fragment") {
          val url = URL.decode("/docs/intro#section-1").toOption.get
          assertTrue(
            url.path == Path.decode("/docs/intro"),
            url.queryParams.isEmpty,
            url.fragment.map(_.raw) == Some("section-1"),
          )
        },
        test("path with query and fragment") {
          val url = URL.decode("/search?q=test#results").toOption.get
          assertTrue(
            url.path == Path.decode("/search"),
            url.queryParams.queryParam("q") == Some("test"),
            url.fragment.map(_.raw) == Some("results"),
          )
        },
        test("matches java.net.URI behavior for relative URLs") {
          val urls = List(
            "/",
            "/api/v1/items",
            "/users?ord=ASC&txt=scala%20is%20awesome%21&u=1&u=2",
            "/users#the%20hash",
            "/a/b/c?x=1&y=2#frag",
            "/search#results?q=test",
            "/path?",
            "/path#",
            "/path?key=val%26ue&other=123",
            "/path?empty=&also=",
            "/%E4%B8%AD%E6%96%87/path",
            "/a/b/c?x=1&x=2&x=3",
            "/path?q=hello+world",
            "/deeply/nested/path/to/resource",
            "/trailing/slash/",
          )
          assertTrue(urls.forall { raw =>
            val fast = URL.decode(raw).toOption.get
            val uri  = new java.net.URI(raw)
            val slow = URL.fromURI(uri).get
            fast.path == slow.path &&
            fast.queryParams == slow.queryParams &&
            fast.fragment.map(_.raw) == slow.fragment.map(_.raw)
          })
        },
        test("absolute URLs still go through java.net.URI") {
          val url = URL.decode("http://abc.com/users?a=1").toOption.get
          assertTrue(
            url.kind.isAbsolute,
            url.path == Path.decode("/users"),
          )
        },
        test("absolute URL with braces in query matches relative decoding") {
          val relative    = URL.decode("/t?v={bla}")
          val absolute    = URL.decode("https://example.com/t?v={bla}")
          val relativeUrl = relative.toOption.get
          val absoluteUrl = absolute.toOption.get

          assertTrue(
            relative.isRight,
            absolute.isRight,
            relativeUrl.path == absoluteUrl.path,
            relativeUrl.queryParams == absoluteUrl.queryParams,
            absoluteUrl.kind == URL.Location.Absolute(Scheme.HTTPS, "example.com", None),
          )
        },
      ),
    )
}
