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

import * as Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import * as Platform from '../platform/platform.js';
import * as Root from '../root/root.js';

import {type Attribute, Cookie} from './Cookie.js';
import {Events as NetworkManagerEvents, NetworkManager} from './NetworkManager.js';
import type {Resource} from './Resource.js';
import {Events as ResourceTreeModelEvents, ResourceTreeModel} from './ResourceTreeModel.js';
import {SDKModel} from './SDKModel.js';
import {Capability, type Target} from './Target.js';

export class CookieModel extends SDKModel<EventTypes> {
  readonly #blockedCookies = new Map<string, Cookie>();
  readonly #cookieToBlockedReasons = new Map<Cookie, BlockedReason[]>();
  readonly #refreshThrottler = new Common.Throttler.Throttler(300);
  #cookies = new Map<string, Cookie[]>();

  constructor(target: Target) {
    super(target);

    target.model(ResourceTreeModel)
        ?.addEventListener(ResourceTreeModelEvents.PrimaryPageChanged, this.#onPrimaryPageChanged, this);
    target.model(NetworkManager)
        ?.addEventListener(NetworkManagerEvents.ResponseReceived, this.#onResponseReceived, this);
    target.model(NetworkManager)?.addEventListener(NetworkManagerEvents.LoadingFinished, this.#onLoadingFinished, this);
  }

  addBlockedCookie(cookie: Cookie, blockedReasons: BlockedReason[]|null): void {
    const key = cookie.key();
    const previousCookie = this.#blockedCookies.get(key);
    this.#blockedCookies.set(key, cookie);
    if (blockedReasons) {
      this.#cookieToBlockedReasons.set(cookie, blockedReasons);
    } else {
      this.#cookieToBlockedReasons.delete(cookie);
    }
    if (previousCookie) {
      this.#cookieToBlockedReasons.delete(previousCookie);
    }
  }

  removeBlockedCookie(cookie: Cookie): void {
    this.#blockedCookies.delete(cookie.key());
  }

  async #onPrimaryPageChanged(): Promise<void> {
    this.#blockedCookies.clear();
    this.#cookieToBlockedReasons.clear();
    await this.#refresh();
  }

  getCookieToBlockedReasonsMap(): ReadonlyMap<Cookie, BlockedReason[]> {
    return this.#cookieToBlockedReasons;
  }

  async #getCookies(urls: Platform.MapUtilities.Multimap<string, string>): Promise<void> {
    const networkAgent = this.target().networkAgent();
    const newCookies = new Map<string, Cookie[]>(await Promise.all(urls.keysArray().map(
        domain => networkAgent.invoke_getCookies({urls: [...urls.get(domain).values()]})
                      .then(({cookies}) => [domain, cookies.map(Cookie.fromProtocolCookie)] as const))));
    const updated = this.#isUpdated(newCookies);
    this.#cookies = newCookies;
    if (updated) {
      this.dispatchEventToListeners(Events.COOKIE_LIST_UPDATED);
    }
  }

  async deleteCookie(cookie: Cookie): Promise<void> {
    await this.deleteCookies([cookie]);
  }

  async clear(domain?: string, securityOrigin?: string): Promise<void> {
    if (!this.#isRefreshing()) {
      await this.#refreshThrottled();
    }
    const cookies = domain ? (this.#cookies.get(domain) || []) : [...this.#cookies.values()].flat();
    cookies.push(...this.#blockedCookies.values());
    if (securityOrigin) {
      const cookiesToDelete = cookies.filter(cookie => {
        return cookie.matchesSecurityOrigin(securityOrigin);
      });
      await this.deleteCookies(cookiesToDelete);
    } else {
      await this.deleteCookies(cookies);
    }
  }

  async saveCookie(cookie: Cookie): Promise<boolean> {
    let domain = cookie.domain();
    if (!domain.startsWith('.')) {
      domain = '';
    }
    let expires: number|undefined = undefined;
    if (cookie.expires()) {
      expires = Math.floor(Date.parse(`${cookie.expires()}`) / 1000);
    }
    const enabled = Root.Runtime.experiments.isEnabled('experimental-cookie-features');
    const preserveUnset = (scheme: Protocol.Network.CookieSourceScheme): Protocol.Network.CookieSourceScheme.Unset|
                          undefined => scheme === Protocol.Network.CookieSourceScheme.Unset ? scheme : undefined;
    const protocolCookie = {
      name: cookie.name(),
      value: cookie.value(),
      url: cookie.url() || undefined,
      domain,
      path: cookie.path(),
      secure: cookie.secure(),
      httpOnly: cookie.httpOnly(),
      sameSite: cookie.sameSite(),
      expires,
      priority: cookie.priority(),
      partitionKey: cookie.partitionKey(),
      sourceScheme: enabled ? cookie.sourceScheme() : preserveUnset(cookie.sourceScheme()),
      sourcePort: enabled ? cookie.sourcePort() : undefined,
    };
    const response = await this.target().networkAgent().invoke_setCookie(protocolCookie);
    const error = response.getError();
    if (error || !response.success) {
      return false;
    }
    await this.#refreshThrottled();
    return response.success;
  }

  /**
   * Returns cookies needed by current page's frames whose security origins are |domain|.
   */
  async getCookiesForDomain(domain: string, forceUpdate?: boolean): Promise<Cookie[]> {
    if (!this.#isRefreshing() || forceUpdate) {
      await this.#refreshThrottled();
    }
    const normalCookies = this.#cookies.get(domain) || [];
    return normalCookies.concat(Array.from(this.#blockedCookies.values()));
  }

  async deleteCookies(cookies: Cookie[]): Promise<void> {
    const networkAgent = this.target().networkAgent();
    this.#blockedCookies.clear();
    this.#cookieToBlockedReasons.clear();
    await Promise.all(cookies.map(cookie => networkAgent.invoke_deleteCookies({
      name: cookie.name(),
      url: undefined,
      domain: cookie.domain(),
      path: cookie.path(),
      partitionKey: cookie.partitionKey(),
    })));
    await this.#refreshThrottled();
  }

  #isRefreshing(): boolean {
    return Boolean(this.listeners?.size);
  }

  #isUpdated(newCookies: Map<string, Cookie[]>): boolean {
    if (newCookies.size !== this.#cookies.size) {
      return true;
    }
    for (const [domain, newDomainCookies] of newCookies) {
      if (!this.#cookies.has(domain)) {
        return true;
      }
      const oldDomainCookies = this.#cookies.get(domain) || [];
      if (newDomainCookies.length !== oldDomainCookies.length) {
        return true;
      }
      const comparisonKey = (c: Cookie): string => c.key() + ' ' + c.value();
      const oldDomainCookieKeys = new Set(oldDomainCookies.map(comparisonKey));
      for (const newCookie of newDomainCookies) {
        if (!oldDomainCookieKeys.has(comparisonKey(newCookie))) {
          return true;
        }
      }
    }
    return false;
  }

  #refreshThrottled(): Promise<void> {
    return this.#refreshThrottler.schedule(() => this.#refresh());
  }

  #refresh(): Promise<void> {
    const resourceURLs = new Platform.MapUtilities.Multimap<string, string>();
    function populateResourceURLs(resource: Resource): boolean {
      const documentURL = Common.ParsedURL.ParsedURL.fromString(resource.documentURL);
      if (documentURL) {
        resourceURLs.set(documentURL.securityOrigin(), resource.url);
      }
      return false;
    }
    const resourceTreeModel = this.target().model(ResourceTreeModel);
    if (resourceTreeModel) {
      // In case the current frame was unreachable, add its cookies
      // because they might help to debug why the frame was unreachable.
      const unreachableUrl = resourceTreeModel.mainFrame?.unreachableUrl();
      if (unreachableUrl) {
        const documentURL = Common.ParsedURL.ParsedURL.fromString(unreachableUrl);
        if (documentURL) {
          resourceURLs.set(documentURL.securityOrigin(), unreachableUrl);
        }
      }

      resourceTreeModel.forAllResources(populateResourceURLs);
    }
    return this.#getCookies(resourceURLs);
  }

  #onResponseReceived(): void {
    if (this.#isRefreshing()) {
      void this.#refreshThrottled();
    }
  }

  #onLoadingFinished(): void {
    if (this.#isRefreshing()) {
      void this.#refreshThrottled();
    }
  }
}

SDKModel.register(CookieModel, {capabilities: Capability.NETWORK, autostart: false});
export interface BlockedReason {
  uiString: string;
  attribute: Attribute|null;
}
export interface ExemptionReason {
  uiString: string;
}

export const enum Events {
  COOKIE_LIST_UPDATED = 'CookieListUpdated',
}

export interface EventTypes {
  [Events.COOKIE_LIST_UPDATED]: void;
}
