# based on go implementation in src/net/http/cookie.go

# Copyright (c) 2009 The Go Authors. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
#    * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#    * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following disclaimer
# in the documentation and/or other materials provided with the
# distribution.
#    * Neither the name of Google Inc. nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

module Cookies

export Cookie, CookieJar, cookies, stringify, getcookies!, setcookies!, addcookie!

import Base: ==
using Dates, URIs, Sockets
using ..IOExtras, ..Parsers, ..Messages

@enum SameSite SameSiteDefaultMode=1 SameSiteLaxMode SameSiteStrictMode SameSiteNoneMode

"""
    Cookie()
    Cookie(; kwargs...)
    Cookie(name, value; kwargs...)

A `Cookie` represents an HTTP cookie as sent in the `"Set-Cookie"` header of an
HTTP response or the Cookie header of an HTTP request. Supported fields
(which can be set using keyword arguments) include:

  * `name::String`: name of the cookie
  * `value::String`: value of the cookie
  * `path::String`: applicable path for the cookie
  * `domain::String`: applicable domain for the cookie
  * `expires::Dates.DateTime`: when the cookie should expire
  * `maxage::Int`: `maxage == 0` means no max age, `maxage < 0` means delete cookie now, `maxage > 0` means the # of seconds until expiration
  * `secure::Bool`: secure cookie attribute
  * `httponly::Bool`: httponly cookie attribute
  * `hostonly::Bool`: hostonly cookie attribute
  * `samesite::Bool`: SameSite cookie attribute

See [IETF RFC 6265](http://tools.ietf.org/html/rfc6265) for details.

The string representation of a cookie is generated by calling [`stringify(cookie; isrequest=true)`](@ref),
where `isrequest=true` will only include the `name=value` pair for requests, and if `false`, will
generate the `"Set-Cookie"` representation for a response header.

A `Vector{Cookie}` can be retrieved from a `Request`/`Response` object by calling [`Cookies.cookies(r)`](@ref).

A `Cookie` can be added to a `Request`/`Response` object by calling [`Cookies.addcookie!(r, cookie)`](@ref).
"""
mutable struct Cookie
    name::String
    value::String

    path::String      # optional
    domain::String    # optional
    expires::Dates.DateTime # optional
    rawexpires::String # for reading cookies only
    creation::Dates.DateTime
    lastaccess::Dates.DateTime

    # MaxAge=0 means no 'Max-Age' attribute specified.
    # MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'
    # MaxAge>0 means Max-Age attribute present and given in seconds
    maxage::Int
    secure::Bool
    httponly::Bool
    persistent::Bool
    hostonly::Bool
    samesite::SameSite
    raw::String
    unparsed::Vector{String} # Raw text of unparsed attribute-value pairs
end

function Cookie(cookie::Cookie; kwargs...)
    for (k, v) in kwargs
        setfield!(cookie, k, convert(fieldtype(Cookie, k), v))
    end
    return cookie
end
Cookie(; kwargs...) = Cookie(Cookie("", "", ""); kwargs...)

Cookie(name, value, raw=""; args...) =
    Cookie(Cookie(
        String(name), String(value), "", "",
        Dates.DateTime(1), "", Dates.DateTime(1), Dates.DateTime(1), 0,
        false, false, false, false, SameSiteDefaultMode, String(raw), String[]); args...)

Base.isequal(a::Cookie, b::Cookie) = a.name == b.name && a.path == b.path && a.domain == b.domain
Base.hash(x::Cookie, h::UInt) = hash(x.name, hash(x.path, hash(x.domain, h)))
id(c::Cookie) = "$(c.domain);$(c.path);$(c.name)"

==(a::Cookie,b::Cookie) = (a.name     == b.name)         &&
                          (a.value    == b.value)        &&
                          (a.path     == b.path)         &&
                          (a.domain   == b.domain)       &&
                          (a.expires  == b.expires)      &&
                          (a.creation == b.creation)     &&
                          (a.lastaccess == b.lastaccess) &&
                          (a.maxage   == b.maxage)       &&
                          (a.secure   == b.secure)       &&
                          (a.httponly == b.httponly)     &&
                          (a.persistent == b.persistent) &&
                          (a.hostonly == b.hostonly)     &&
                          (a.samesite == b.samesite)

"""
    stringify(cookie::Cookie, isrequest=true)

Generate the string representation of a cookie. By default, `isrequest=true`,
and only the `name=value` pair will be included in the cookie string. For `isrequest=false`,
the other cookie attributes will be included, `;`-separated, for use in a "Set-Cookie" header.
"""
function stringify(c::Cookie, isrequest::Bool=true)
    nm = strip(c.name)
    !iscookienamevalid(nm) && return ""
    io = IOBuffer()
    write(io, sanitizeCookieName(nm), '=', sanitizeCookieValue(c.value))
    if !isrequest
        length(c.path) > 0 && write(io, "; Path=", sanitizeCookiePath(c.path))
        length(c.domain) > 0 && validCookieDomain(c.domain) && write(io, "; Domain=", c.domain[1] == '.' ? SubString(c.domain, 2) : c.domain)
        validCookieExpires(c.expires) && write(io, "; Expires=", Dates.format(c.expires, Dates.RFC1123Format), " GMT")
        c.maxage > 0 && write(io, "; Max-Age=", string(c.maxage))
        c.maxage < 0 && write(io, "; Max-Age=0")
        c.httponly && write(io, "; HttpOnly")
        # c.hostonly && write(io, "; HostOnly")
        c.secure && write(io, "; Secure")
        if c.samesite == SameSiteLaxMode
            write(io, "; SameSite=Lax")
        elseif c.samesite == SameSiteStrictMode
            write(io, "; SameSite=Strict")
        elseif c.samesite == SameSiteNoneMode
            write(io, "; SameSite=None")
        end
    end
    return String(take!(io))
end

function stringify(cookiestring::AbstractString, cookies::Vector{Cookie}, isrequest::Bool=true)
    io = IOBuffer()
    if !isempty(cookiestring)
        write(io, cookiestring)
        if !isempty(cookies)
            if endswith(rstrip(cookiestring, [' ']), ";")
                cookiestring[end] == ';' && write(io, ' ')
            else
                write(io, "; ")
            end
        end
    end
    len = length(cookies)
    for (i, cookie) in enumerate(cookies)
        write(io, stringify(cookie, isrequest), ifelse(i == len, "", "; "))
    end
    return String(take!(io))
end

"""
    Cookies.addcookie!(r::Union{HTTP.Request, HTTP.Response}, c::Cookie)

Convenience function for adding a single cookie to a request or response object.
For requests, the cookie will be stringified and concatenated to any existing
"Cookie" header. For responses, an additional "Set-Cookie" header will be appended.
"""
function addcookie! end

function addcookie!(r::Request, c::Cookie)
    cstr = stringify(c)
    chead = header(r, "Cookie", nothing)
    if chead !== nothing
        setheader(r, "Cookie" => "$chead; $cstr")
    else
        appendheader(r, SubString("Cookie") => SubString(cstr))
    end
    return r
end

function addcookie!(r::Response, c::Cookie)
    appendheader(r, SubString("Set-Cookie") => SubString(stringify(c, false)))
    return r
end

validcookiepathbyte(b) = (' ' <= b < '\x7f') && b != ';'
validcookievaluebyte(b) = (' ' <= b < '\x7f') && b != '"' && b != ';' && b != '\\'

function parsecookievalue(raw, allowdoublequote::Bool)
    if allowdoublequote && length(raw) > 1 && raw[1] == '"' && raw[end] == '"'
        raw = raw[2:end-1]
    end
    for i = eachindex(raw)
        !validcookievaluebyte(raw[i]) && return "", false
    end
    return raw, true
end

iscookienamevalid(raw) = raw == "" ? false : any(isurlchar, raw)

gmtformat(::DateFormat{S,T}) where {S,T} = Dates.DateFormat(string(S, " G\\MT"))
const AlternateRFC1123GMTFormat = gmtformat(dateformat"e, dd-uuu-yyyy HH:MM:SS")
const RFC1123GMTFormat = gmtformat(Dates.RFC1123Format)

# readSetCookies parses all "Set-Cookie" values from
# the header h and returns the successfully parsed Cookies.
function readsetcookies(h::Headers)
    result = Cookie[]
    for line in headers(h, "Set-Cookie")
        if length(line) == 0
            continue
        end
        parts = split(strip(line), ';'; keepempty=false)
        if length(parts) == 1 && parts[1] == ""
            continue
        end
        part = strip(parts[1])
        j = findfirst(isequal('='), part)
        if j !== nothing
            name, val = SubString(part, 1:j-1), SubString(part, j+1)
        else
            name, val = part, ""
        end
        !iscookienamevalid(name) && continue
        val, ok = parsecookievalue(val, true)
        !ok && continue
        c = Cookie(name, val, line)
        for i = 2:length(parts)
            part = strip(parts[i])
            isempty(part) && continue
            j = findfirst(isequal('='), part)
            if j !== nothing
                attr, val = SubString(part, 1:j-1), SubString(part, j+1)
            else
                attr, val = part, ""
            end
            !isascii(attr) && continue
            lowerattr = lowercase(attr)
            val, ok = parsecookievalue(val, false)
            if !ok
                push!(c.unparsed, part)
                continue
            end
            if lowerattr == "samesite"
                if !isascii(val)
                    c.samesite = SameSiteDefaultMode
                    continue
                end
                val = lowercase(val)
                if val == "lax"
                    c.samesite = SameSiteLaxMode
                elseif val == "strict"
                    c.samesite = SameSiteStrictMode
                elseif val == "none"
                    c.samesite = SameSiteNoneMode
                else
                    c.samesite = SameSiteDefaultMode
                end
            elseif lowerattr == "secure"
                c.secure = true
            elseif lowerattr == "httponly"
                c.httponly = true
            # elseif lowerattr == "hostonly"
            #     c.hostonly = true
            elseif lowerattr == "domain"
                c.domain = val
            elseif lowerattr == "max-age"
                try
                    secs = parse(Int, val)
                    val[1] == '0' && continue
                    c.maxage = max(secs, -1)
                catch
                    continue
                end
            elseif lowerattr == "expires"
                c.rawexpires = val
                try
                    c.expires = Dates.DateTime(val, RFC1123GMTFormat)
                catch
                    try
                        c.expires = Dates.DateTime(val, AlternateRFC1123GMTFormat)
                    catch
                        continue
                    end
                end
            elseif lowerattr == "path"
                c.path = val
            else
                push!(c.unparsed, parts[i])
            end
        end
        push!(result, c)
    end
    return result
end

readsetcookies(h) = readsetcookies(mkheaders(h))

function isIP(host)
    try
        Base.parse(IPAddr, host)
        return true
    catch e
        isa(e, ArgumentError) && return false
        rethrow(e)
    end
end

"""
    HTTP.cookies(r::Union{Request, Response}) -> Vector{Cookie}

Return a list of cookies, if any, parsed from the request "Cookie"
or response "Set-Cookie" headers.
"""
function cookies end
cookies(r::Response) = readsetcookies(r.headers)
cookies(r::Request) = readcookies(r.headers, "")

# readCookies parses all "Cookie" values from the header h and
# returns the successfully parsed Cookies.
# if filter isn't empty, only cookies of that name are returned
function readcookies(h::Headers, filter::String="")
    result = Cookie[]
    for line in headers(h, "Cookie")
        for part in split(strip(line), ';'; keepempty=false)
            part = strip(part)
            length(part) <= 1 && continue
            j = findfirst(isequal('='), part)
            if j !== nothing
                name, val = part[1:j-1], part[j+1:end]
            else
                name, val = part, ""
            end
            !iscookienamevalid(name) && continue
            filter != "" && filter != name && continue
            val, ok = parsecookievalue(val, true)
            !ok && continue
            push!(result, Cookie(name, val, line))
        end
    end
    return result
end

readcookies(h, f) = readcookies(mkheaders(h), f)

# validCookieExpires returns whether v is a valid cookie expires-value.
function validCookieExpires(dt)
    # IETF RFC 6265 Section 5.1.1.5, the year must not be less than 1601
    return Dates.year(dt) >= 1601
end

# validCookieDomain returns whether v is a valid cookie domain-value.
function validCookieDomain(v::String)
    isCookieDomainName(v) && return true
    isIP(v) && !occursin(":", v) && return true
    return false
end

# isCookieDomainName returns whether s is a valid domain name or a valid
# domain name with a leading dot '.'.  It is almost a direct copy of
# package net's isDomainName.
function isCookieDomainName(s::String)
    length(s) == 0 && return false
    length(s) > 255 && return false
    s = s[1] == '.' ? s[2:end] : s
    last = '.'
    ok = false
    partlen = 0
    for c in s
        if 'a' <= c <= 'z' || 'A' <= c <= 'Z'
            ok = true
            partlen += 1
        elseif '0' <= c <= '9'
            partlen += 1
        elseif c == '-'
            last == '.' && return false
            partlen += 1
        elseif c == '.'
            (last == '.' || last == '-') && return false
            (partlen > 63 || partlen == 0) && return false
            partlen = 0
        else
            return false
        end
        last = c
    end
    (last == '-' || partlen > 63) && return false
    return ok
end

sanitizeCookieName(n::String) = replace(replace(n, '\n'=>'-'), '\r'=>'-')
sanitizeCookieName(n) = sanitizeCookieName(String(n))

# http:#tools.ietf.org/html/rfc6265#section-4.1.1
# cookie-value      = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
# cookie-octet      = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
#           ; US-ASCII characters excluding CTLs,
#           ; whitespace DQUOTE, comma, semicolon,
#           ; and backslash
# We loosen this as spaces and commas are common in cookie values
# but we produce a quoted cookie-value in when value starts or ends
# with a comma or space.
# See https:#golang.org/issue/7243 for the discussion.
function sanitizeCookieValue(v::String)
    v = String(filter(validcookievaluebyte, [Char(b) for b in bytes(v)]))
    length(v) == 0 && return v
    if contains(v, ' ') || contains(v, ',')
        return string('"', v, '"')
    end
    return v
end

sanitizeCookiePath(v) = filter(validcookiepathbyte, v)

const normal_url_char = Bool[
#=   0 nul    1 soh    2 stx    3 etx    4 eot    5 enq    6 ack    7 bel  =#
        false,   false,   false,   false,   false,   false,   false,   false,
#=   8 bs     9 ht    10 nl    11 vt    12 np    13 cr    14 so    15 si   =#
        false,   true,   false,   false,   true,   false,   false,   false,
#=  16 dle   17 dc1   18 dc2   19 dc3   20 dc4   21 nak   22 syn   23 etb =#
        false,   false,   false,   false,   false,   false,   false,   false,
#=  24 can   25 em    26 sub   27 esc   28 fs    29 gs    30 rs    31 us  =#
        false,   false,   false,   false,   false,   false,   false,   false,
#=  32 sp    33  !    34  "    35  #    36  $    37  %    38  &    39  '  =#
        false,   true,   true,   false,   true,   true,   true,  true,
#=  40  (    41  )    42  *    43  +    44  ,    45  -    46  .    47  /  =#
        true,   true,   true,   true,   true,   true,   true,  true,
#=  48  0    49  1    50  2    51  3    52  4    53  5    54  6    55  7  =#
        true,   true,   true,   true,   true,   true,   true,  true,
#=  56  8    57  9    58  :    59  ;    60  <    61  =    62  >    63  ?  =#
        true,   true,   true,   true,   true,   true,   true,   false,
#=  64  @    65  A    66  B    67  C    68  D    69  E    70  F    71  G  =#
        true,   true,   true,   true,   true,   true,   true,  true,
#=  72  H    73  I    74  J    75  K    76  L    77  M    78  N    79  O  =#
        true,   true,   true,   true,   true,   true,   true,  true,
#=  80  P    81  Q    82  R    83  S    84  T    85  U    86  V    87  W  =#
        true,   true,   true,   true,   true,   true,   true,  true,
#=  88  X    89  Y    90  Z    91  [    92  \    93  ]    94  ^    95  _  =#
        true,   true,   true,   true,   true,   true,   true,  true,
#=  96  `    97  a    98  b    99  c   100  d   101  e   102  f   103  g  =#
        true,   true,   true,   true,   true,   true,   true,  true,
#= 104  h   105  i   106  j   107  k   108  l   109  m   110  n   111  o  =#
        true,   true,   true,   true,   true,   true,   true,  true,
#= 112  p   113  q   114  r   115  s   116  t   117  u   118  v   119  w  =#
        true,   true,   true,   true,   true,   true,   true,  true,
#= 120  x   121  y   122  z   123  {   124,   125  }   126  ~   127 del =#
        true,   true,   true,   true,   true,   true,   true,   false,
]

@inline isurlchar(c) =  c > '\u80' ? true : normal_url_char[Int(c) + 1]

include("cookiejar.jl")

end # module
