// Copyright 2020 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 Common from '../../core/common/common.js';
import type * as Protocol from '../../generated/protocol.js';
import * as IssuesManager from '../../models/issues_manager/issues_manager.js';

interface AggregationKeyTag {
  aggregationKeyTag: undefined;
}

/**
 * An opaque type for the key which we use to aggregate issues. The key must be
 * chosen such that if two aggregated issues have the same aggregation key, then
 * they also have the same issue code.
 */
export type AggregationKey = {
  toString(): string,
}&AggregationKeyTag;

/**
 * An `AggregatedIssue` representes a number of `IssuesManager.Issue.Issue` objects that are displayed together.
 * Currently only grouping by issue code, is supported. The class provides helpers to support displaying
 * of all resources that are affected by the aggregated issues.
 */
export class AggregatedIssue extends IssuesManager.Issue.Issue {
  #affectedCookies = new Map<string, {
    cookie: Protocol.Audits.AffectedCookie,
    hasRequest: boolean,
  }>();
  #affectedRawCookieLines = new Map<string, {rawCookieLine: string, hasRequest: boolean}>();
  #affectedRequests: Protocol.Audits.AffectedRequest[] = [];
  #affectedRequestIds = new Set<Protocol.Network.RequestId>();
  #affectedLocations = new Map<string, Protocol.Audits.SourceCodeLocation>();
  #heavyAdIssues = new Set<IssuesManager.HeavyAdIssue.HeavyAdIssue>();
  #blockedByResponseDetails = new Map<string, Protocol.Audits.BlockedByResponseIssueDetails>();
  #bounceTrackingSites = new Set<string>();
  #corsIssues = new Set<IssuesManager.CorsIssue.CorsIssue>();
  #cspIssues = new Set<IssuesManager.ContentSecurityPolicyIssue.ContentSecurityPolicyIssue>();
  #deprecationIssues = new Set<IssuesManager.DeprecationIssue.DeprecationIssue>();
  #issueKind = IssuesManager.Issue.IssueKind.IMPROVEMENT;
  #lowContrastIssues = new Set<IssuesManager.LowTextContrastIssue.LowTextContrastIssue>();
  #cookieDeprecationMetadataIssues =
      new Set<IssuesManager.CookieDeprecationMetadataIssue.CookieDeprecationMetadataIssue>();
  #mixedContentIssues = new Set<IssuesManager.MixedContentIssue.MixedContentIssue>();
  #partitioningBlobURLIssues = new Set<IssuesManager.PartitioningBlobURLIssue.PartitioningBlobURLIssue>();
  #sharedArrayBufferIssues = new Set<IssuesManager.SharedArrayBufferIssue.SharedArrayBufferIssue>();
  #quirksModeIssues = new Set<IssuesManager.QuirksModeIssue.QuirksModeIssue>();
  #attributionReportingIssues = new Set<IssuesManager.AttributionReportingIssue.AttributionReportingIssue>();
  #genericIssues = new Set<IssuesManager.GenericIssue.GenericIssue>();
  #elementAccessibilityIssues = new Set<IssuesManager.ElementAccessibilityIssue.ElementAccessibilityIssue>();
  #representative?: IssuesManager.Issue.Issue;
  #aggregatedIssuesCount = 0;
  #key: AggregationKey;

  constructor(code: string, aggregationKey: AggregationKey) {
    super(code);
    this.#key = aggregationKey;
  }

  override primaryKey(): string {
    throw new Error('This should never be called');
  }

  aggregationKey(): AggregationKey {
    return this.#key;
  }

  override getBlockedByResponseDetails(): Iterable<Protocol.Audits.BlockedByResponseIssueDetails> {
    return this.#blockedByResponseDetails.values();
  }

  override cookies(): Iterable<Protocol.Audits.AffectedCookie> {
    return Array.from(this.#affectedCookies.values()).map(x => x.cookie);
  }

  getRawCookieLines(): Iterable<{rawCookieLine: string, hasRequest: boolean}> {
    return this.#affectedRawCookieLines.values();
  }

  override sources(): Iterable<Protocol.Audits.SourceCodeLocation> {
    return this.#affectedLocations.values();
  }

  getBounceTrackingSites(): Iterable<string> {
    return this.#bounceTrackingSites.values();
  }

  cookiesWithRequestIndicator(): Iterable<{
    cookie: Protocol.Audits.AffectedCookie,
    hasRequest: boolean,
  }> {
    return this.#affectedCookies.values();
  }

  getHeavyAdIssues(): Iterable<IssuesManager.HeavyAdIssue.HeavyAdIssue> {
    return this.#heavyAdIssues;
  }

  getCookieDeprecationMetadataIssues():
      Iterable<IssuesManager.CookieDeprecationMetadataIssue.CookieDeprecationMetadataIssue> {
    return this.#cookieDeprecationMetadataIssues;
  }

  getMixedContentIssues(): Iterable<IssuesManager.MixedContentIssue.MixedContentIssue> {
    return this.#mixedContentIssues;
  }

  getCorsIssues(): Set<IssuesManager.CorsIssue.CorsIssue> {
    return this.#corsIssues;
  }

  getCspIssues(): Iterable<IssuesManager.ContentSecurityPolicyIssue.ContentSecurityPolicyIssue> {
    return this.#cspIssues;
  }

  getDeprecationIssues(): Iterable<IssuesManager.DeprecationIssue.DeprecationIssue> {
    return this.#deprecationIssues;
  }

  getLowContrastIssues(): Iterable<IssuesManager.LowTextContrastIssue.LowTextContrastIssue> {
    return this.#lowContrastIssues;
  }

  override requests(): Iterable<Protocol.Audits.AffectedRequest> {
    return this.#affectedRequests.values();
  }

  getSharedArrayBufferIssues(): Iterable<IssuesManager.SharedArrayBufferIssue.SharedArrayBufferIssue> {
    return this.#sharedArrayBufferIssues;
  }

  getQuirksModeIssues(): Iterable<IssuesManager.QuirksModeIssue.QuirksModeIssue> {
    return this.#quirksModeIssues;
  }

  getAttributionReportingIssues(): ReadonlySet<IssuesManager.AttributionReportingIssue.AttributionReportingIssue> {
    return this.#attributionReportingIssues;
  }

  getGenericIssues(): ReadonlySet<IssuesManager.GenericIssue.GenericIssue> {
    return this.#genericIssues;
  }

  getElementAccessibilityIssues(): Iterable<IssuesManager.ElementAccessibilityIssue.ElementAccessibilityIssue> {
    return this.#elementAccessibilityIssues;
  }

  getDescription(): IssuesManager.MarkdownIssueDescription.MarkdownIssueDescription|null {
    if (this.#representative) {
      return this.#representative.getDescription();
    }
    return null;
  }

  getCategory(): IssuesManager.Issue.IssueCategory {
    if (this.#representative) {
      return this.#representative.getCategory();
    }
    return IssuesManager.Issue.IssueCategory.OTHER;
  }

  getAggregatedIssuesCount(): number {
    return this.#aggregatedIssuesCount;
  }

  getPartitioningBlobURLIssues(): Iterable<IssuesManager.PartitioningBlobURLIssue.PartitioningBlobURLIssue> {
    return this.#partitioningBlobURLIssues;
  }

  /**
   * Produces a primary key for a cookie. Use this instead of `JSON.stringify` in
   * case new fields are added to `AffectedCookie`.
   */
  #keyForCookie(cookie: Protocol.Audits.AffectedCookie): string {
    const {domain, path, name} = cookie;
    return `${domain};${path};${name}`;
  }

  addInstance(issue: IssuesManager.Issue.Issue): void {
    this.#aggregatedIssuesCount++;
    if (!this.#representative) {
      this.#representative = issue;
    }
    this.#issueKind = IssuesManager.Issue.unionIssueKind(this.#issueKind, issue.getKind());
    let hasRequest = false;
    for (const request of issue.requests()) {
      const {requestId} = request;
      hasRequest = true;
      if (requestId === undefined) {
        this.#affectedRequests.push(request);
      } else if (!this.#affectedRequestIds.has(requestId)) {
        this.#affectedRequests.push(request);
        this.#affectedRequestIds.add(requestId);
      }
    }
    for (const cookie of issue.cookies()) {
      const key = this.#keyForCookie(cookie);
      if (!this.#affectedCookies.has(key)) {
        this.#affectedCookies.set(key, {cookie, hasRequest});
      }
    }
    for (const rawCookieLine of issue.rawCookieLines()) {
      if (!this.#affectedRawCookieLines.has(rawCookieLine)) {
        this.#affectedRawCookieLines.set(rawCookieLine, {rawCookieLine, hasRequest});
      }
    }
    for (const site of issue.trackingSites()) {
      if (!this.#bounceTrackingSites.has(site)) {
        this.#bounceTrackingSites.add(site);
      }
    }
    for (const location of issue.sources()) {
      const key = JSON.stringify(location);
      if (!this.#affectedLocations.has(key)) {
        this.#affectedLocations.set(key, location);
      }
    }
    if (issue instanceof IssuesManager.CookieDeprecationMetadataIssue.CookieDeprecationMetadataIssue) {
      this.#cookieDeprecationMetadataIssues.add(issue);
    }
    if (issue instanceof IssuesManager.MixedContentIssue.MixedContentIssue) {
      this.#mixedContentIssues.add(issue);
    }
    if (issue instanceof IssuesManager.HeavyAdIssue.HeavyAdIssue) {
      this.#heavyAdIssues.add(issue);
    }
    for (const details of issue.getBlockedByResponseDetails()) {
      const key = JSON.stringify(details, ['parentFrame', 'blockedFrame', 'requestId', 'frameId', 'reason', 'request']);
      this.#blockedByResponseDetails.set(key, details);
    }
    if (issue instanceof IssuesManager.ContentSecurityPolicyIssue.ContentSecurityPolicyIssue) {
      this.#cspIssues.add(issue);
    }
    if (issue instanceof IssuesManager.DeprecationIssue.DeprecationIssue) {
      this.#deprecationIssues.add(issue);
    }
    if (issue instanceof IssuesManager.SharedArrayBufferIssue.SharedArrayBufferIssue) {
      this.#sharedArrayBufferIssues.add(issue);
    }
    if (issue instanceof IssuesManager.LowTextContrastIssue.LowTextContrastIssue) {
      this.#lowContrastIssues.add(issue);
    }
    if (issue instanceof IssuesManager.CorsIssue.CorsIssue) {
      this.#corsIssues.add(issue);
    }
    if (issue instanceof IssuesManager.QuirksModeIssue.QuirksModeIssue) {
      this.#quirksModeIssues.add(issue);
    }
    if (issue instanceof IssuesManager.AttributionReportingIssue.AttributionReportingIssue) {
      this.#attributionReportingIssues.add(issue);
    }
    if (issue instanceof IssuesManager.GenericIssue.GenericIssue) {
      this.#genericIssues.add(issue);
    }
    if (issue instanceof IssuesManager.ElementAccessibilityIssue.ElementAccessibilityIssue) {
      this.#elementAccessibilityIssues.add(issue);
    }
    if (issue instanceof IssuesManager.PartitioningBlobURLIssue.PartitioningBlobURLIssue) {
      this.#partitioningBlobURLIssues.add(issue);
    }
  }

  getKind(): IssuesManager.Issue.IssueKind {
    return this.#issueKind;
  }

  override isHidden(): boolean {
    return this.#representative?.isHidden() || false;
  }

  override setHidden(_value: boolean): void {
    throw new Error('Should not call setHidden on aggregatedIssue');
  }
}

export class IssueAggregator extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
  readonly #aggregatedIssuesByKey = new Map<AggregationKey, AggregatedIssue>();
  readonly #hiddenAggregatedIssuesByKey = new Map<AggregationKey, AggregatedIssue>();
  constructor(private readonly issuesManager: IssuesManager.IssuesManager.IssuesManager) {
    super();
    this.issuesManager.addEventListener(IssuesManager.IssuesManager.Events.ISSUE_ADDED, this.#onIssueAdded, this);
    this.issuesManager.addEventListener(
        IssuesManager.IssuesManager.Events.FULL_UPDATE_REQUIRED, this.#onFullUpdateRequired, this);
    for (const issue of this.issuesManager.issues()) {
      this.#aggregateIssue(issue);
    }
  }

  #onIssueAdded(event: Common.EventTarget.EventTargetEvent<IssuesManager.IssuesManager.IssueAddedEvent>): void {
    this.#aggregateIssue(event.data.issue);
  }

  #onFullUpdateRequired(): void {
    this.#aggregatedIssuesByKey.clear();
    this.#hiddenAggregatedIssuesByKey.clear();
    for (const issue of this.issuesManager.issues()) {
      this.#aggregateIssue(issue);
    }
    this.dispatchEventToListeners(Events.FULL_UPDATE_REQUIRED);
  }

  #aggregateIssue(issue: IssuesManager.Issue.Issue): AggregatedIssue|undefined {
    if (IssuesManager.CookieIssue.CookieIssue.isThirdPartyCookiePhaseoutRelatedIssue(issue)) {
      return;
    }

    const map = issue.isHidden() ? this.#hiddenAggregatedIssuesByKey : this.#aggregatedIssuesByKey;
    const aggregatedIssue = this.#aggregateIssueByStatus(map, issue);
    this.dispatchEventToListeners(Events.AGGREGATED_ISSUE_UPDATED, aggregatedIssue);
    return aggregatedIssue;
  }

  #aggregateIssueByStatus(aggregatedIssuesMap: Map<AggregationKey, AggregatedIssue>, issue: IssuesManager.Issue.Issue):
      AggregatedIssue {
    const key = issue.code() as unknown as AggregationKey;
    let aggregatedIssue = aggregatedIssuesMap.get(key);
    if (!aggregatedIssue) {
      aggregatedIssue = new AggregatedIssue(issue.code(), key);
      aggregatedIssuesMap.set(key, aggregatedIssue);
    }
    aggregatedIssue.addInstance(issue);
    return aggregatedIssue;
  }

  aggregatedIssues(): Iterable<AggregatedIssue> {
    return [...this.#aggregatedIssuesByKey.values(), ...this.#hiddenAggregatedIssuesByKey.values()];
  }

  aggregatedIssueCodes(): Set<AggregationKey> {
    return new Set([...this.#aggregatedIssuesByKey.keys(), ...this.#hiddenAggregatedIssuesByKey.keys()]);
  }

  aggregatedIssueCategories(): Set<IssuesManager.Issue.IssueCategory> {
    const result = new Set<IssuesManager.Issue.IssueCategory>();
    for (const issue of this.#aggregatedIssuesByKey.values()) {
      result.add(issue.getCategory());
    }
    return result;
  }

  aggregatedIssueKinds(): Set<IssuesManager.Issue.IssueKind> {
    const result = new Set<IssuesManager.Issue.IssueKind>();
    for (const issue of this.#aggregatedIssuesByKey.values()) {
      result.add(issue.getKind());
    }
    return result;
  }

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

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

  keyForIssue(issue: IssuesManager.Issue.Issue<string>): AggregationKey {
    return issue.code() as unknown as AggregationKey;
  }
}

export const enum Events {
  AGGREGATED_ISSUE_UPDATED = 'AggregatedIssueUpdated',
  FULL_UPDATE_REQUIRED = 'FullUpdateRequired',
}

export interface EventTypes {
  [Events.AGGREGATED_ISSUE_UPDATED]: AggregatedIssue;
  [Events.FULL_UPDATE_REQUIRED]: void;
}
