// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import type * as Protocol from '../../generated/protocol.js';
import type * as Platform from '../platform/platform.js';

const OPAQUE_PARTITION_KEY = '<opaque>';

export class Cookie {
  readonly #name: string;
  readonly #value: string;
  readonly #type: Type|null|undefined;
  #attributes = new Map<Attribute, string|number|boolean|undefined>();
  #size = 0;
  #priority: Protocol.Network.CookiePriority;
  #cookieLine: string|null = null;
  #partitionKey: Protocol.Network.CookiePartitionKey|undefined;

  constructor(
      name: string, value: string, type?: Type|null, priority?: Protocol.Network.CookiePriority,
      partitionKey?: Protocol.Network.CookiePartitionKey) {
    this.#name = name;
    this.#value = value;
    this.#type = type;
    this.#priority = (priority || 'Medium' as Protocol.Network.CookiePriority);
    this.#partitionKey = partitionKey;
  }

  static fromProtocolCookie(protocolCookie: Protocol.Network.Cookie): Cookie {
    const cookie = new Cookie(protocolCookie.name, protocolCookie.value, null, protocolCookie.priority);
    cookie.addAttribute(Attribute.DOMAIN, protocolCookie['domain']);
    cookie.addAttribute(Attribute.PATH, protocolCookie['path']);
    if (protocolCookie['expires']) {
      cookie.addAttribute(Attribute.EXPIRES, protocolCookie['expires'] * 1000);
    }
    if (protocolCookie['httpOnly']) {
      cookie.addAttribute(Attribute.HTTP_ONLY);
    }
    if (protocolCookie['secure']) {
      cookie.addAttribute(Attribute.SECURE);
    }
    if (protocolCookie['sameSite']) {
      cookie.addAttribute(Attribute.SAME_SITE, protocolCookie['sameSite']);
    }
    if ('sourcePort' in protocolCookie) {
      cookie.addAttribute(Attribute.SOURCE_PORT, protocolCookie.sourcePort);
    }
    if ('sourceScheme' in protocolCookie) {
      cookie.addAttribute(Attribute.SOURCE_SCHEME, protocolCookie.sourceScheme);
    }
    if ('partitionKey' in protocolCookie) {
      if (protocolCookie.partitionKey) {
        cookie.setPartitionKey(
            protocolCookie.partitionKey.topLevelSite, protocolCookie.partitionKey.hasCrossSiteAncestor);
      }
    }
    if ('partitionKeyOpaque' in protocolCookie && protocolCookie.partitionKeyOpaque) {
      cookie.addAttribute(Attribute.PARTITION_KEY, OPAQUE_PARTITION_KEY);
    }
    cookie.setSize(protocolCookie['size']);
    return cookie;
  }

  key(): string {
    return (this.domain() || '-') + ' ' + this.name() + ' ' + (this.path() || '-') + ' ' +
        (this.partitionKey() ?
             (this.topLevelSite() + ' ' + (this.hasCrossSiteAncestor() ? 'cross_site' : 'same_site')) :
             '-');
  }

  name(): string {
    return this.#name;
  }

  value(): string {
    return this.#value;
  }

  type(): Type|null|undefined {
    return this.#type;
  }

  httpOnly(): boolean {
    return this.#attributes.has(Attribute.HTTP_ONLY);
  }

  secure(): boolean {
    return this.#attributes.has(Attribute.SECURE);
  }

  partitioned(): boolean {
    return this.#attributes.has(Attribute.PARTITIONED) || Boolean(this.partitionKey()) || this.partitionKeyOpaque();
  }

  sameSite(): Protocol.Network.CookieSameSite {
    // TODO(allada) This should not rely on #attributes and instead store them individually.
    // when #attributes get added via addAttribute() they are lowercased, hence the lowercasing of samesite here
    return this.#attributes.get(Attribute.SAME_SITE) as Protocol.Network.CookieSameSite;
  }

  partitionKey(): Protocol.Network.CookiePartitionKey {
    return this.#partitionKey as Protocol.Network.CookiePartitionKey;
  }

  setPartitionKey(topLevelSite: string, hasCrossSiteAncestor: boolean): void {
    this.#partitionKey = {topLevelSite, hasCrossSiteAncestor};
    if (!this.#attributes.has(Attribute.PARTITIONED)) {
      this.addAttribute(Attribute.PARTITIONED);
    }
  }

  topLevelSite(): string {
    if (!this.#partitionKey) {
      return '';
    }
    return this.#partitionKey?.topLevelSite;
  }

  setTopLevelSite(topLevelSite: string, hasCrossSiteAncestor: boolean): void {
    this.setPartitionKey(topLevelSite, hasCrossSiteAncestor);
  }

  hasCrossSiteAncestor(): boolean {
    if (!this.#partitionKey) {
      return false;
    }
    return this.#partitionKey?.hasCrossSiteAncestor;
  }

  setHasCrossSiteAncestor(hasCrossSiteAncestor: boolean): void {
    if (!this.partitionKey() || !Boolean(this.topLevelSite())) {
      return;
    }
    this.setPartitionKey(this.topLevelSite(), hasCrossSiteAncestor);
  }

  partitionKeyOpaque(): boolean {
    if (!this.#partitionKey) {
      return false;
    }
    return (this.topLevelSite() === OPAQUE_PARTITION_KEY);
  }

  setPartitionKeyOpaque(): void {
    this.addAttribute(Attribute.PARTITION_KEY, OPAQUE_PARTITION_KEY);
    this.setPartitionKey(OPAQUE_PARTITION_KEY, false);
  }

  priority(): Protocol.Network.CookiePriority {
    return this.#priority;
  }

  session(): boolean {
    // RFC 2965 suggests using Discard attribute to mark session cookies, but this does not seem to be widely used.
    // Check for absence of explicitly max-age or expiry date instead.
    return !(this.#attributes.has(Attribute.EXPIRES) || this.#attributes.has(Attribute.MAX_AGE));
  }

  path(): string {
    return this.#attributes.get(Attribute.PATH) as string;
  }

  domain(): string {
    return this.#attributes.get(Attribute.DOMAIN) as string;
  }

  expires(): number {
    return this.#attributes.get(Attribute.EXPIRES) as number;
  }

  maxAge(): number {
    return this.#attributes.get(Attribute.MAX_AGE) as number;
  }

  sourcePort(): number {
    return this.#attributes.get(Attribute.SOURCE_PORT) as number;
  }

  sourceScheme(): Protocol.Network.CookieSourceScheme {
    return this.#attributes.get(Attribute.SOURCE_SCHEME) as Protocol.Network.CookieSourceScheme;
  }

  size(): number {
    return this.#size;
  }

  /**
   * @deprecated
   */
  url(): Platform.DevToolsPath.UrlString|null {
    if (!this.domain() || !this.path()) {
      return null;
    }
    let port = '';
    const sourcePort = this.sourcePort();
    // Do not include standard ports to ensure the back-end will change standard ports according to the scheme.
    if (sourcePort && sourcePort !== 80 && sourcePort !== 443) {
      port = `:${this.sourcePort()}`;
    }
    // We must not consider the this.sourceScheme() here, otherwise it will be impossible to set a cookie without
    // the Secure attribute from a secure origin.
    return (this.secure() ? 'https://' : 'http://') + this.domain() + port + this.path() as
        Platform.DevToolsPath.UrlString;
  }

  setSize(size: number): void {
    this.#size = size;
  }

  expiresDate(requestDate: Date): Date|null {
    // RFC 6265 indicates that the max-age attribute takes precedence over the expires attribute
    if (this.maxAge()) {
      return new Date(requestDate.getTime() + 1000 * this.maxAge());
    }

    if (this.expires()) {
      return new Date(this.expires());
    }

    return null;
  }

  addAttribute(key: Attribute|null, value?: string|number|boolean): void {
    if (!key) {
      return;
    }
    switch (key) {
      case Attribute.PRIORITY:
        this.#priority = (value as Protocol.Network.CookiePriority);
        break;
      default:
        this.#attributes.set(key, value);
    }
  }

  hasAttribute(key: Attribute): boolean {
    return this.#attributes.has(key);
  }

  getAttribute(key: Attribute): string|number|boolean|undefined {
    return this.#attributes.get(key);
  }

  setCookieLine(cookieLine: string): void {
    this.#cookieLine = cookieLine;
  }

  getCookieLine(): string|null {
    return this.#cookieLine;
  }

  matchesSecurityOrigin(securityOrigin: string): boolean {
    const hostname = new URL(securityOrigin).hostname;
    return Cookie.isDomainMatch(this.domain(), hostname);
  }

  static isDomainMatch(domain: string, hostname: string): boolean {
    // This implementation mirrors
    // https://source.chromium.org/search?q=net::cookie_util::IsDomainMatch()
    //
    // Can domain match in two ways; as a domain cookie (where the cookie
    // domain begins with ".") or as a host cookie (where it doesn't).

    // Some consumers of the CookieMonster expect to set cookies on
    // URLs like http://.strange.url.  To retrieve cookies in this instance,
    // we allow matching as a host cookie even when the domain_ starts with
    // a period.
    if (hostname === domain) {
      return true;
    }

    // Domain cookie must have an initial ".".  To match, it must be
    // equal to url's host with initial period removed, or a suffix of
    // it.

    // Arguably this should only apply to "http" or "https" cookies, but
    // extension cookie tests currently use the funtionality, and if we
    // ever decide to implement that it should be done by preventing
    // such cookies from being set.
    if (domain?.[0] !== '.') {
      return false;
    }

    // The host with a "." prefixed.
    if (domain.substr(1) === hostname) {
      return true;
    }

    // A pure suffix of the host (ok since we know the domain already
    // starts with a ".")
    return hostname.length > domain.length && hostname.endsWith(domain);
  }
}

export const enum Type {
  REQUEST = 0,
  RESPONSE = 1,
}

export const enum Attribute {
  NAME = 'name',
  VALUE = 'value',
  SIZE = 'size',
  DOMAIN = 'domain',
  PATH = 'path',
  EXPIRES = 'expires',
  MAX_AGE = 'max-age',
  HTTP_ONLY = 'http-only',
  SECURE = 'secure',
  SAME_SITE = 'same-site',
  SOURCE_SCHEME = 'source-scheme',
  SOURCE_PORT = 'source-port',
  PRIORITY = 'priority',
  PARTITIONED = 'partitioned',
  PARTITION_KEY = 'partition-key',
  PARTITION_KEY_SITE = 'partition-key-site',
  HAS_CROSS_SITE_ANCESTOR = 'has-cross-site-ancestor',
}
