# :markup: markdown
# frozen_string_literal: true
require 'http/cookie/version'
require 'http/cookie/uri_parser'
require 'time'
require 'uri'
require 'domain_name'
require 'http/cookie/ruby_compat'
require 'cgi'

module HTTP
  autoload :CookieJar, 'http/cookie_jar'
end

# This class is used to represent an HTTP Cookie.
class HTTP::Cookie
  # Maximum number of bytes per cookie (RFC 6265 6.1 requires 4096 at
  # least)
  MAX_LENGTH = 4096
  # Maximum number of cookies per domain (RFC 6265 6.1 requires 50 at
  # least)
  MAX_COOKIES_PER_DOMAIN = 50
  # Maximum number of cookies total (RFC 6265 6.1 requires 3000 at
  # least)
  MAX_COOKIES_TOTAL = 3000

  # :stopdoc:
  UNIX_EPOCH = Time.at(0)

  PERSISTENT_PROPERTIES = %w[
    name        value
    domain      for_domain  path
    secure      httponly
    expires     max_age
    created_at  accessed_at
  ]
  # :startdoc:

  # The cookie name.  It may not be nil or empty.
  #
  # Assign a string containing any of the following characters will
  # raise ArgumentError: control characters (`\x00-\x1F` and `\x7F`),
  # space and separators `,;\"=`.
  #
  # Note that RFC 6265 4.1.1 lists more characters disallowed for use
  # in a cookie name, which are these: `<>@:/[]?{}`.  Using these
  # characters will reduce interoperability.
  #
  # :attr_accessor: name

  # The cookie value.
  #
  # Assign a string containing a control character (`\x00-\x1F` and
  # `\x7F`) will raise ArgumentError.
  #
  # Assigning nil sets the value to an empty string and the expiration
  # date to the Unix epoch.  This is a handy way to make a cookie for
  # expiration.
  #
  # Note that RFC 6265 4.1.1 lists more characters disallowed for use
  # in a cookie value, which are these: ` ",;\`.  Using these
  # characters will reduce interoperability.
  #
  # :attr_accessor: value

  # The cookie domain.
  #
  # Setting a domain with a leading dot implies that the #for_domain
  # flag should be turned on.  The setter accepts a DomainName object
  # as well as a string-like.
  #
  # :attr_accessor: domain

  # The path attribute value.
  #
  # The setter treats an empty path ("") as the root path ("/").
  #
  # :attr_accessor: path

  # The origin of the cookie.
  #
  # Setting this will initialize the #domain and #path attribute
  # values if unknown yet.  If the cookie already has a domain value
  # set, it is checked against the origin URL to see if the origin is
  # allowed to issue a cookie of the domain, and ArgumentError is
  # raised if the check fails.
  #
  # :attr_accessor: origin

  # The Expires attribute value as a Time object.
  #
  # The setter method accepts a Time / DateTime object, a string representation
  # of date/time that Time.parse can understand, or `nil`.
  #
  # Setting this value resets #max_age to nil.  When #max_age is
  # non-nil, #expires returns `created_at + max_age`.
  #
  # :attr_accessor: expires

  # The Max-Age attribute value as an integer, the number of seconds
  # before expiration.
  #
  # The setter method accepts an integer, or a string-like that
  # represents an integer which will be stringified and then
  # integerized using #to_i.
  #
  # This value is reset to nil when #expires= is called.
  #
  # :attr_accessor: max_age

  # :call-seq:
  #     new(name, value = nil)
  #     new(name, value = nil, **attr_hash)
  #     new(**attr_hash)
  #
  # Creates a cookie object.  For each key of `attr_hash`, the setter
  # is called if defined and any error (typically ArgumentError or
  # TypeError) that is raised will be passed through.  Each key can be
  # either a downcased symbol or a string that may be mixed case.
  # Support for the latter may, however, be obsoleted in future when
  # Ruby 2.0's keyword syntax is adopted.
  #
  # If `value` is omitted or it is nil, an expiration cookie is
  # created unless `max_age` or `expires` (`expires_at`) is given.
  #
  # e.g.
  #
  #     new("uid", "a12345")
  #     new("uid", "a12345", :domain => 'example.org',
  #                          :for_domain => true, :expired => Time.now + 7*86400)
  #     new("name" => "uid", "value" => "a12345", "Domain" => 'www.example.org')
  #
  def initialize(*args)
    @name = @origin = @domain = @path =
      @expires = @max_age = nil
    @for_domain = @secure = @httponly = false
    @session = true
    @created_at = @accessed_at = Time.now
    case argc = args.size
    when 1
      if attr_hash = Hash.try_convert(args.last)
        args.pop
      else
        self.name, self.value = args # value is set to nil
        return
      end
    when 2..3
      if attr_hash = Hash.try_convert(args.last)
        args.pop
        self.name, value = args
      else
        argc == 2 or
          raise ArgumentError, "wrong number of arguments (#{argc} for 1-3)"
        self.name, self.value = args
        return
      end
    else
      raise ArgumentError, "wrong number of arguments (#{argc} for 1-3)"
    end
    for_domain = false
    domain = max_age = origin = nil
    attr_hash.each_pair { |okey, val|
      case key ||= okey
      when :name
        self.name = val
      when :value
        value = val
      when :domain
        domain = val
      when :path
        self.path = val
      when :origin
        origin = val
      when :for_domain, :for_domain?
        for_domain = val
      when :max_age
        # Let max_age take precedence over expires
        max_age = val
      when :expires, :expires_at
        self.expires = val unless max_age
      when :httponly, :httponly?
        @httponly = val
      when :secure, :secure?
        @secure = val
      when Symbol
        setter = :"#{key}="
        if respond_to?(setter)
          __send__(setter, val)
        else
          warn "unknown attribute name: #{okey.inspect}" if $VERBOSE
          next
        end
      when String
        warn "use downcased symbol for keyword: #{okey.inspect}" if $VERBOSE
        key = key.downcase.to_sym
        redo
      else
        warn "invalid keyword ignored: #{okey.inspect}" if $VERBOSE
        next
      end
    }
    if @name.nil?
      raise ArgumentError, "name must be specified"
    end
    @for_domain = for_domain
    self.domain = domain if domain
    self.origin = origin if origin
    self.max_age = max_age if max_age
    self.value = value.nil? && (@expires || @max_age) ? '' : value
  end

  autoload :Scanner, 'http/cookie/scanner'

  class << self
    # Tests if +target_path+ is under +base_path+ as described in RFC
    # 6265 5.1.4.  +base_path+ must be an absolute path.
    # +target_path+ may be empty, in which case it is treated as the
    # root path.
    #
    # e.g.
    #
    #         path_match?('/admin/', '/admin/index') == true
    #         path_match?('/admin/', '/Admin/index') == false
    #         path_match?('/admin/', '/admin/') == true
    #         path_match?('/admin/', '/admin') == false
    #
    #         path_match?('/admin', '/admin') == true
    #         path_match?('/admin', '/Admin') == false
    #         path_match?('/admin', '/admins') == false
    #         path_match?('/admin', '/admin/') == true
    #         path_match?('/admin', '/admin/index') == true
    def path_match?(base_path, target_path)
      base_path.start_with?('/') or return false
      # RFC 6265 5.1.4
      bsize = base_path.size
      tsize = target_path.size
      return bsize == 1 if tsize == 0 # treat empty target_path as "/"
      return false unless target_path.start_with?(base_path)
      return true if bsize == tsize || base_path.end_with?('/')
      target_path[bsize] == ?/
    end

    # Parses a Set-Cookie header value `set_cookie` assuming that it
    # is sent from a source URI/URL `origin`, and returns an array of
    # Cookie objects.  Parts (separated by commas) that are malformed
    # or considered unacceptable are silently ignored.
    #
    # If a block is given, each cookie object is passed to the block.
    #
    # Available option keywords are below:
    #
    # :created_at
    # : The creation time of the cookies parsed.
    #
    # :logger
    # : Logger object useful for debugging
    #
    # ### Compatibility Note for Mechanize::Cookie users
    #
    # * Order of parameters changed in HTTP::Cookie.parse:
    #
    #         Mechanize::Cookie.parse(uri, set_cookie[, log])
    #
    #         HTTP::Cookie.parse(set_cookie, uri[, :logger => # log])
    #
    # * HTTP::Cookie.parse does not accept nil for `set_cookie`.
    #
    # * HTTP::Cookie.parse does not yield nil nor include nil in an
    #   returned array.  It simply ignores unparsable parts.
    #
    # * HTTP::Cookie.parse is made to follow RFC 6265 to the extent
    #   not terribly breaking interoperability with broken
    #   implementations.  In particular, it is capable of parsing
    #   cookie definitions containing double-quotes just as naturally
    #   expected.
    def parse(set_cookie, origin, options = nil, &block)
      if options
        logger = options[:logger]
        created_at = options[:created_at]
      end
      origin = HTTP::Cookie::URIParser.parse(origin)

      [].tap { |cookies|
        Scanner.new(set_cookie, logger).scan_set_cookie { |name, value, attrs|
          break if name.nil? || name.empty?

          begin
            cookie = new(name, value)
          rescue => e
            logger.warn("Invalid name or value: #{e}") if logger
            next
          end
          cookie.created_at = created_at if created_at
          attrs.each { |aname, avalue|
            begin
              case aname
              when 'domain'
                cookie.for_domain = true
                # The following may negate @for_domain if the value is
                # an eTLD or IP address, hence this order.
                cookie.domain = avalue
              when 'path'
                cookie.path = avalue
              when 'expires'
                # RFC 6265 4.1.2.2
                # The Max-Age attribute has precedence over the Expires
                # attribute.
                cookie.expires = avalue unless cookie.max_age
              when 'max-age'
                cookie.max_age = avalue
              when 'secure'
                cookie.secure = avalue
              when 'httponly'
                cookie.httponly = avalue
              end
            rescue => e
              logger.warn("Couldn't parse #{aname} '#{avalue}': #{e}") if logger
            end
          }

          cookie.origin = origin

          cookie.acceptable? or next

          yield cookie if block_given?

          cookies << cookie
        }
      }
    end

    # Takes an array of cookies and returns a string for use in the
    # Cookie header, like "name1=value2; name2=value2".
    def cookie_value(cookies)
      cookies.join('; ')
    end

    # Parses a Cookie header value into a hash of name-value string
    # pairs.  The first appearance takes precedence if multiple pairs
    # with the same name occur.
    def cookie_value_to_hash(cookie_value)
      {}.tap { |hash|
        Scanner.new(cookie_value).scan_cookie { |name, value|
          hash[name] ||= value
        }
      }
    end
  end

  attr_reader :name

  # See #name.
  def name= name
    name = (String.try_convert(name) or
      raise TypeError, "#{name.class} is not a String")
    if name.empty?
      raise ArgumentError, "cookie name cannot be empty"
    elsif name.match(/[\x00-\x20\x7F,;\\"=]/)
      raise ArgumentError, "invalid cookie name"
    end
    # RFC 6265 4.1.1
    # cookie-name may not match:
    # /[\x00-\x20\x7F()<>@,;:\\"\/\[\]?={}]/
    @name = name
  end

  attr_reader :value

  # See #value.
  def value= value
    if value.nil?
      self.expires = UNIX_EPOCH
      return @value = ''
    end
    value = (String.try_convert(value) or
      raise TypeError, "#{value.class} is not a String")
    if value.match(/[\x00-\x1F\x7F]/)
      raise ArgumentError, "invalid cookie value"
    end
    # RFC 6265 4.1.1
    # cookie-name may not match:
    # /[^\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]/
    @value = value
  end

  attr_reader :domain

  # See #domain.
  def domain= domain
    case domain
    when nil
      @for_domain = false
      if @origin
        @domain_name = DomainName.new(@origin.host)
        @domain = @domain_name.hostname
      else
        @domain_name = @domain = nil
      end
      return nil
    when DomainName
      @domain_name = domain
    else
      domain = (String.try_convert(domain) or
        raise TypeError, "#{domain.class} is not a String")
      if domain.start_with?('.')
        for_domain = true
        domain = domain[1..-1]
      end
      if domain.empty?
        return self.domain = nil
      end
      # Do we really need to support this?
      if domain.match(/\A([^:]+):[0-9]+\z/)
        domain = $1
      end
      @domain_name = DomainName.new(domain)
    end
    # RFC 6265 5.3 5.
    if domain_name.domain.nil? # a public suffix or IP address
      @for_domain = false
    else
      @for_domain = for_domain unless for_domain.nil?
    end
    @domain = @domain_name.hostname
  end

  # Returns the domain, with a dot prefixed only if the domain flag is
  # on.
  def dot_domain
    @for_domain ? (+'.') << @domain : @domain
  end

  # Returns the domain attribute value as a DomainName object.
  attr_reader :domain_name

  # The domain flag. (the opposite of host-only-flag)
  #
  # If this flag is true, this cookie will be sent to any host in the
  # \#domain, including the host domain itself.  If it is false, this
  # cookie will be sent only to the host indicated by the #domain.
  attr_accessor :for_domain
  alias for_domain? for_domain

  attr_reader :path

  # See #path.
  def path= path
    path = (String.try_convert(path) or
      raise TypeError, "#{path.class} is not a String")
    @path = path.start_with?('/') ? path : '/'
  end

  attr_reader :origin

  # See #origin.
  def origin= origin
    return origin if origin == @origin
    @origin.nil? or
      raise ArgumentError, "origin cannot be changed once it is set"
    # Delay setting @origin because #domain= or #path= may fail
    origin = HTTP::Cookie::URIParser.parse(origin)
    if URI::HTTP === origin
      self.domain ||= origin.host
      self.path   ||= (origin + './').path
    end
    @origin = origin
  end

  # The secure flag. (secure-only-flag)
  #
  # A cookie with this flag on should only be sent via a secure
  # protocol like HTTPS.
  attr_accessor :secure
  alias secure? secure

  # The HttpOnly flag. (http-only-flag)
  #
  # A cookie with this flag on should be hidden from a client script.
  attr_accessor :httponly
  alias httponly? httponly

  # The session flag. (the opposite of persistent-flag)
  #
  # A cookie with this flag on should be hidden from a client script.
  attr_reader :session
  alias session? session

  def expires
    @expires or @created_at && @max_age ? @created_at + @max_age : nil
  end

  # See #expires.
  def expires= t
    case t
    when nil, Time
    when DateTime
      t = t.to_time
    else
      t = Time.parse(t)
    end
    @max_age = nil
    @session = t.nil?
    @expires = t
  end

  alias expires_at expires
  alias expires_at= expires=

  attr_reader :max_age

  # See #max_age.
  def max_age= sec
    case sec
    when Integer, nil
    else
      str = String.try_convert(sec) or
        raise TypeError, "#{sec.class} is not an Integer or String"
      /\A-?\d+\z/.match(str) or
        raise ArgumentError, "invalid Max-Age: #{sec.inspect}"
      sec = str.to_i
    end
    @expires = nil
    if @session = sec.nil?
      @max_age = nil
    else
      @max_age = sec
    end
  end

  # Tests if this cookie is expired by now, or by a given time.
  def expired?(time = Time.now)
    if expires = self.expires
      expires <= time
    else
      false
    end
  end

  # Expires this cookie by setting the expires attribute value to a
  # past date.
  def expire!
    self.expires = UNIX_EPOCH
    self
  end

  # The time this cookie was created at.  This value is used as a base
  # date for interpreting the Max-Age attribute value.  See #expires.
  attr_accessor :created_at

  # The time this cookie was last accessed at.
  attr_accessor :accessed_at

  # Tests if it is OK to accept this cookie if it is sent from a given
  # URI/URL, `uri`.
  def acceptable_from_uri?(uri)
    uri = HTTP::Cookie::URIParser.parse(uri)
    return false unless URI::HTTP === uri && uri.host
    host = DomainName.new(uri.host)

    # RFC 6265 5.3
    case
    when host.hostname == @domain
      true
    when @for_domain  # !host-only-flag
      host.cookie_domain?(@domain_name)
    else
      @domain.nil?
    end
  end

  # Tests if it is OK to accept this cookie considering its origin.
  # If either domain or path is missing, raises ArgumentError.  If
  # origin is missing, returns true.
  def acceptable?
    case
    when @domain.nil?
      raise "domain is missing"
    when @path.nil?
      raise "path is missing"
    when @origin.nil?
      true
    else
      acceptable_from_uri?(@origin)
    end
  end

  # Tests if it is OK to send this cookie to a given `uri`.  A
  # RuntimeError is raised if the cookie's domain is unknown.
  def valid_for_uri?(uri)
    if @domain.nil?
      raise "cannot tell if this cookie is valid because the domain is unknown"
    end
    uri = HTTP::Cookie::URIParser.parse(uri)
    # RFC 6265 5.4
    return false if secure? && !(URI::HTTPS === uri)
    acceptable_from_uri?(uri) && HTTP::Cookie.path_match?(@path, uri.path)
  end

  # Returns a string for use in the Cookie header, i.e. `name=value`
  # or `name="value"`.
  def cookie_value
    +"#{@name}=#{Scanner.quote(@value)}"
  end
  alias to_s cookie_value

  # Returns a string for use in the Set-Cookie header.  If necessary
  # information like a path or domain (when `for_domain` is set) is
  # missing, RuntimeError is raised.  It is always the best to set an
  # origin before calling this method.
  def set_cookie_value
    string = cookie_value
    if @for_domain
      if @domain
        string << "; Domain=#{@domain}"
      else
        raise "for_domain is specified but domain is unknown"
      end
    end
    if @path
      if !@origin || (@origin + './').path != @path
        string << "; Path=#{@path}"
      end
    else
      raise "path is unknown"
    end
    if @max_age
      string << "; Max-Age=#{@max_age}"
    elsif @expires
      string << "; Expires=#{@expires.httpdate}"
    end
    if @httponly
      string << "; HttpOnly"
    end
    if @secure
      string << "; Secure"
    end
    string
  end

  def inspect
    '#<%s:' % self.class << PERSISTENT_PROPERTIES.map { |key|
      '%s=%s' % [key, instance_variable_get(:"@#{key}").inspect]
    }.join(', ') << ' origin=%s>' % [@origin ? @origin.to_s : 'nil']
  end

  # Compares the cookie with another.  When there are many cookies with
  # the same name for a URL, the value of the smallest must be used.
  def <=> other
    # RFC 6265 5.4
    # Precedence: 1. longer path  2. older creation
    (@name <=> other.name).nonzero? ||
      (other.path.length <=> @path.length).nonzero? ||
      (@created_at <=> other.created_at).nonzero? ||
      @value <=> other.value
  end
  include Comparable

  # YAML serialization helper for Syck.
  def to_yaml_properties
    PERSISTENT_PROPERTIES.map { |name| "@#{name}" }
  end

  # YAML serialization helper for Psych.
  def encode_with(coder)
    PERSISTENT_PROPERTIES.each { |key|
      coder[key.to_s] = instance_variable_get(:"@#{key}")
    }
  end

  # YAML deserialization helper for Syck.
  def init_with(coder)
    yaml_initialize(coder.tag, coder.map)
  end

  # YAML deserialization helper for Psych.
  def yaml_initialize(tag, map)
    expires = nil
    @origin = nil
    map.each { |key, value|
      case key
      when 'expires'
        # avoid clobbering max_age
        expires = value
      when *PERSISTENT_PROPERTIES
        __send__(:"#{key}=", value)
      end
    }
    self.expires = expires if self.max_age.nil?
  end
end
