chromium/content/browser/resources/attribution_reporting/attribution_internals.ts

// 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 'chrome://resources/cr_elements/cr_tab_box/cr_tab_box.js';
import './attribution_detail_table.js';
import './attribution_internals_table.js';

import type {Origin} from 'chrome://resources/mojo/url/mojom/origin.mojom-webui.js';

import {AggregatableResult} from './aggregatable_result.mojom-webui.js';
import {AttributionSupport} from './attribution.mojom-webui.js';
import type {AttributionDetailTableElement} from './attribution_detail_table.js';
import type {HandlerInterface, NetworkStatus, ObserverInterface, ReportID, ReportStatus, WebUIAggregatableDebugReport, WebUIDebugReport, WebUIOsRegistration, WebUIRegistration, WebUIReport, WebUISource, WebUISourceRegistration, WebUITrigger} from './attribution_internals.mojom-webui.js';
import {Factory, HandlerRemote, ObserverReceiver, WebUISource_Attributability} from './attribution_internals.mojom-webui.js';
import type {AttributionInternalsTableElement, CompareFunc, DataColumn, InitOpts, RenderFunc} from './attribution_internals_table.js';
import {OsRegistrationResult, RegistrationType} from './attribution_reporting.mojom-webui.js';
import {EventLevelResult} from './event_level_result.mojom-webui.js';
import {ProcessAggregatableDebugReportResult} from './process_aggregatable_debug_report_result.mojom-webui.js';
import {SourceType} from './source_type.mojom-webui.js';
import {StoreSourceResult} from './store_source_result.mojom-webui.js';
import {TriggerDataMatching} from './trigger_data_matching.mojom-webui.js';

// If kAttributionAggregatableBudgetPerSource changes, update this value
const BUDGET_PER_SOURCE = 65536;

type Comparable = bigint|number|string|boolean|Date;

function compareDefault<T extends Comparable>(a: T, b: T): number {
  if (a < b) {
    return -1;
  }
  if (a > b) {
    return 1;
  }
  return 0;
}

function undefinedFirst<V>(f: CompareFunc<V>): CompareFunc<V|undefined> {
  return (a: V|undefined, b: V|undefined): number => {
    if (a === undefined && b === undefined) {
      return 0;
    }
    if (a === undefined) {
      return -1;
    }
    if (b === undefined) {
      return 1;
    }
    return f(a, b);
  };
}

function compareLexicographic<V>(f: CompareFunc<V>): CompareFunc<V[]> {
  return (a: V[], b: V[]): number => {
    for (let i = 0; i < a.length && i < b.length; ++i) {
      const r = f(a[i]!, b[i]!);
      if (r !== 0) {
        return r;
      }
    }
    return compareDefault(a.length, b.length);
  };
}

function bigintReplacer(_key: string, value: any): any {
  return typeof value === 'bigint' ? value.toString() : value;
}

interface Valuable<V> {
  readonly compare?: CompareFunc<V>;
  readonly render: RenderFunc<V>;
}

function allowingUndefined<V>({render, compare}: Valuable<V>):
    Valuable<V|undefined> {
  return {
    compare: compare ? undefinedFirst(compare) : undefined,
    render: (td: HTMLElement, v: V|undefined) => {
      if (v !== undefined) {
        render(td, v);
      }
    },
  };
}

function valueColumn<T, K extends keyof T>(
    label: string, key: K, {render, compare}: Valuable<T[K]>,
    defaultSort: boolean = false): DataColumn<T> {
  return {
    label,
    render: (td, data) => render(td, data[key]),
    compare: compare ? (a, b) => compare(a[key], b[key]) : undefined,
    defaultSort,
  };
}

const asDate: Valuable<Date> = {
  compare: compareDefault,
  render: (td: HTMLElement, v: Date) => {
    const time = td.ownerDocument.createElement('time');
    time.dateTime = v.toISOString();
    time.innerText = v.toLocaleString();
    td.replaceChildren(time);
  },
};

const numberClass: string = 'number';

const asNumber: Valuable<bigint|number> = {
  compare: compareDefault,
  render: (td: HTMLElement, v: bigint|number) => {
    td.classList.add(numberClass);
    td.innerText = v.toString();
  },
};

function asCustomNumber<V extends bigint|number>(fmt: (v: V) => string):
    Valuable<V> {
  return {
    compare: compareDefault,
    render: (td: HTMLElement, v: V) => {
      td.classList.add(numberClass);
      td.innerText = fmt(v);
    },
  };
}

const asStringOrBool: Valuable<string|boolean> = {
  compare: compareDefault,
  render: (td: HTMLElement, v: string|boolean) => td.innerText = v.toString(),
};

const asCode: Valuable<string> = {
  render: (td: HTMLElement, v: string) => {
    const code = td.ownerDocument.createElement('code');
    code.innerText = v;

    const pre = td.ownerDocument.createElement('pre');
    pre.append(code);

    td.replaceChildren(pre);
  },
};

function asList<V>({render, compare}: Valuable<V>): Valuable<V[]> {
  return {
    compare: compare ? compareLexicographic(compare) : undefined,
    render: (td: HTMLElement, vs: V[]) => {
      if (vs.length === 0) {
        td.replaceChildren();
        return;
      }

      const ul = td.ownerDocument.createElement('ul');

      for (const v of vs) {
        const li = td.ownerDocument.createElement('li');
        render(li, v);
        ul.append(li);
      }

      td.replaceChildren(ul);
    },
  };
}

function renderUrl(td: HTMLElement, url: string): void {
  const a = td.ownerDocument.createElement('a');
  a.target = '_blank';
  a.href = url;
  a.innerText = url;
  td.replaceChildren(a);
}

const asUrl: Valuable<string> = {
  compare: compareDefault,
  render: renderUrl,
};

function isAttributionSuccessDebugReport(url: string): boolean {
  return url.includes('/.well-known/attribution-reporting/debug/');
}

interface Source {
  id: bigint;
  sourceEventId: bigint;
  sourceOrigin: string;
  destinations: string[];
  reportingOrigin: string;
  sourceTime: Date;
  expiryTime: Date;
  triggerSpecs: string;
  aggregatableReportWindowTime: Date;
  sourceType: string;
  filterData: string;
  aggregationKeys: string;
  debugKey?: bigint;
  dedupKeys: bigint[];
  priority: bigint;
  status: string;
  remainingAggregatableAttributionBudget: number;
  aggregatableDedupKeys: bigint[];
  triggerDataMatching: string;
  eventLevelEpsilon: number;
  debugCookieSet: boolean;
  remainingAggregatableDebugBudget: number;
  aggregatableDebugKeyPiece: string;
  attributionScopesData: string;
}

function newSource(mojo: WebUISource): Source {
  return {
    id: mojo.id,
    sourceEventId: mojo.sourceEventId,
    sourceOrigin: originToText(mojo.sourceOrigin),
    destinations:
        mojo.destinations.destinations.map(d => originToText(d.siteAsOrigin))
            .sort(compareDefault),
    reportingOrigin: originToText(mojo.reportingOrigin),
    sourceTime: new Date(mojo.sourceTime),
    expiryTime: new Date(mojo.expiryTime),
    triggerSpecs: mojo.triggerSpecsJson,
    aggregatableReportWindowTime: new Date(mojo.aggregatableReportWindowTime),
    sourceType: sourceTypeText[mojo.sourceType],
    priority: mojo.priority,
    filterData: JSON.stringify(mojo.filterData.filterValues, null, ' '),
    aggregationKeys: JSON.stringify(mojo.aggregationKeys, bigintReplacer, ' '),
    debugKey: mojo.debugKey ?? undefined,
    dedupKeys: mojo.dedupKeys.sort(compareDefault),
    remainingAggregatableAttributionBudget:
        mojo.remainingAggregatableAttributionBudget,
    aggregatableDedupKeys: mojo.aggregatableDedupKeys.sort(compareDefault),
    triggerDataMatching: triggerDataMatchingText[mojo.triggerDataMatching],
    eventLevelEpsilon: mojo.eventLevelEpsilon,
    status: attributabilityText[mojo.attributability],
    debugCookieSet: mojo.debugCookieSet,
    remainingAggregatableDebugBudget: mojo.remainingAggregatableDebugBudget,
    aggregatableDebugKeyPiece: mojo.aggregatableDebugKeyPiece,
    attributionScopesData: mojo.attributionScopesDataJson,
  };
}

function initSourceTable(panel: HTMLElement):
    AttributionInternalsTableElement<Source> {
  return initPanel(
      panel,
      [
        valueColumn('Source Event ID', 'sourceEventId', asNumber),
        valueColumn('Status', 'status', asStringOrBool),
        valueColumn('Source Origin', 'sourceOrigin', asUrl),
        valueColumn('Destinations', 'destinations', asList(asUrl)),
        valueColumn('Reporting Origin', 'reportingOrigin', asUrl),
        valueColumn(
            'Registration Time', 'sourceTime', asDate, /*defaultSort=*/ true),
        valueColumn('Expiry', 'expiryTime', asDate),
        valueColumn('Source Type', 'sourceType', asStringOrBool),
        valueColumn('Debug Key', 'debugKey', allowingUndefined(asNumber)),
      ],
      {
        getId: source => source.id,
        isSelectable: true,
      },
      [
        valueColumn('Priority', 'priority', asNumber),
        valueColumn('Filter Data', 'filterData', asCode),
        valueColumn('Debug Cookie Set', 'debugCookieSet', asStringOrBool),
        'Event-Level Fields',
        valueColumn('Attribution Scopes Data', 'attributionScopesData', asCode),
        valueColumn(
            'Epsilon', 'eventLevelEpsilon',
            asCustomNumber((v: number) => v.toFixed(3))),
        valueColumn(
            'Trigger Data Matching', 'triggerDataMatching', asStringOrBool),
        valueColumn('Trigger Specs', 'triggerSpecs', asCode),
        valueColumn('Dedup Keys', 'dedupKeys', asList(asNumber)),
        'Aggregatable Fields',
        valueColumn(
            'Report Window Time', 'aggregatableReportWindowTime', asDate),
        valueColumn(
            'Remaining Aggregatable Attribution Budget',
            'remainingAggregatableAttributionBudget',
            asCustomNumber((v) => `${v} / ${BUDGET_PER_SOURCE}`)),
        valueColumn('Aggregation Keys', 'aggregationKeys', asCode),
        valueColumn('Dedup Keys', 'aggregatableDedupKeys', asList(asNumber)),
        valueColumn(
            'Remaining Aggregatable Debug Budget',
            'remainingAggregatableDebugBudget',
            asCustomNumber((v) => `${v} / ${BUDGET_PER_SOURCE}`)),
        valueColumn(
            'Aggregatable Debug Key Piece', 'aggregatableDebugKeyPiece',
            asStringOrBool),
      ]);
}

class Registration {
  readonly time: Date;
  readonly contextOrigin: string;
  readonly reportingOrigin: string;
  readonly registrationJson: string;
  readonly clearedDebugKey?: bigint;

  constructor(mojo: WebUIRegistration) {
    this.time = new Date(mojo.time);
    this.contextOrigin = originToText(mojo.contextOrigin);
    this.reportingOrigin = originToText(mojo.reportingOrigin);
    this.registrationJson = mojo.registrationJson;
    this.clearedDebugKey = mojo.clearedDebugKey ?? undefined;
  }
}

function initRegistrationTableModel<T extends Registration>(
    panel: HTMLElement, contextOriginTitle: string,
    cols: Iterable<DataColumn<T>>): AttributionInternalsTableElement<T> {
  return initPanel(
      panel,
      [
        valueColumn('Time', 'time', asDate, /*defaultSort=*/ true),
        valueColumn(contextOriginTitle, 'contextOrigin', asUrl),
        valueColumn('Reporting Origin', 'reportingOrigin', asUrl),
        valueColumn(
            'Cleared Debug Key', 'clearedDebugKey',
            allowingUndefined(asNumber)),
        ...cols,
      ],
      {isSelectable: true},
      [valueColumn('Registration JSON', 'registrationJson', asCode)]);
}

class Trigger extends Registration {
  readonly eventLevelResult: string;
  readonly aggregatableResult: string;

  constructor(mojo: WebUITrigger) {
    super(mojo.registration);
    this.eventLevelResult = eventLevelResultText[mojo.eventLevelResult];
    this.aggregatableResult = aggregatableResultText[mojo.aggregatableResult];
  }
}

function initTriggerTable(panel: HTMLElement):
    AttributionInternalsTableElement<Trigger> {
  return initRegistrationTableModel(panel, 'Destination', [
    valueColumn('Event-Level Result', 'eventLevelResult', asStringOrBool),
    valueColumn('Aggregatable Result', 'aggregatableResult', asStringOrBool),
  ]);
}

class SourceRegistration extends Registration {
  readonly type: string;
  readonly status: string;

  constructor(mojo: WebUISourceRegistration) {
    super(mojo.registration);
    this.type = sourceTypeText[mojo.type];
    this.status = sourceRegistrationStatusText[mojo.status];
  }
}

function initSourceRegistrationTable(panel: HTMLElement):
    AttributionInternalsTableElement<SourceRegistration> {
  return initRegistrationTableModel(panel, 'Source Origin', [
    valueColumn('Type', 'type', asStringOrBool),
    valueColumn('Status', 'status', asStringOrBool),
  ]);
}

function isHttpError(code: number): boolean {
  return code < 200 || code >= 400;
}

const reportStatusColumn: DataColumn<{status: string, sendFailed: boolean}> = {
  label: 'Status',
  compare: (a, b) => compareDefault(a.status, b.status),
  render: (td, report) => {
    td.classList.toggle('send-error', report.sendFailed);
    td.innerText = report.status;
  },
};

function networkStatusToString(status: NetworkStatus, sentPrefix: string):
    [status: string, sendFailed: boolean] {
  if (status.httpResponseCode !== undefined) {
    return [
      `${sentPrefix}HTTP ${status.httpResponseCode}`,
      isHttpError(status.httpResponseCode),
    ];
  } else if (status.networkError !== undefined) {
    return [`Network error: ${status.networkError}`, true];
  } else {
    throw new Error('invalid NetworkStatus union');
  }
}

class Report {
  id: ReportID;
  reportBody: string;
  reportUrl: string;
  triggerTime: Date;
  reportTime: Date;
  status: string;
  sendFailed: boolean;

  constructor(mojo: WebUIReport) {
    this.id = mojo.id;
    this.reportBody = mojo.reportBody;
    this.reportUrl = mojo.reportUrl.url;
    this.triggerTime = new Date(mojo.triggerTime);
    this.reportTime = new Date(mojo.reportTime);

    [this.status, this.sendFailed] =
        Report.statusToString(mojo.status, 'Sent: ');
  }

  isPending(): boolean {
    return this.status === 'Pending';
  }

  static statusToString(status: ReportStatus, sentPrefix: string):
      [status: string, sendFailed: boolean] {
    if (status.networkStatus !== undefined) {
      return networkStatusToString(status.networkStatus, sentPrefix);
    } else if (status.pending !== undefined) {
      return ['Pending', false];
    } else if (status.replacedByHigherPriorityReport !== undefined) {
      return [
        `Replaced by higher-priority report: ${
            status.replacedByHigherPriorityReport}`,
        false,
      ];
    } else if (status.prohibitedByBrowserPolicy !== undefined) {
      return ['Prohibited by browser policy', false];
    } else if (status.failedToAssemble !== undefined) {
      return ['Dropped due to assembly failure', false];
    } else {
      throw new Error('invalid ReportStatus union');
    }
  }
}

class EventLevelReport extends Report {
  reportPriority: bigint;
  randomizedReport: boolean;

  constructor(mojo: WebUIReport) {
    super(mojo);

    this.reportPriority = mojo.data.eventLevelData!.priority;
    this.randomizedReport = !mojo.data.eventLevelData!.attributedTruthfully;
  }
}

class AggregatableReport extends Report {
  contributions: string;
  aggregationCoordinator: string;
  isNullReport: boolean;

  constructor(mojo: WebUIReport) {
    super(mojo);

    this.contributions = JSON.stringify(
        mojo.data.aggregatableAttributionData!.contributions, bigintReplacer,
        ' ');

    this.aggregationCoordinator =
        mojo.data.aggregatableAttributionData!.aggregationCoordinator;

    this.isNullReport = mojo.data.aggregatableAttributionData!.isNullReport;
  }
}

function initPanel<T>(
    panel: HTMLElement, cols: Iterable<DataColumn<T>>, initOpts: InitOpts<T>,
    detailCols: Iterable<string|DataColumn<T>>,
    onSelectionChange: (data: T|undefined) => void =
        () => {}): AttributionInternalsTableElement<T> {
  const t = panel.querySelector<AttributionInternalsTableElement<T>>(
      'attribution-internals-table')!;

  t.init(cols, initOpts);

  const d = panel.querySelector<AttributionDetailTableElement<T>>(
      'attribution-detail-table')!;

  d.init([...cols, ...detailCols]);

  t.addEventListener(
      'selection-change', (e: CustomEvent<{data: T | undefined}>) => {
        onSelectionChange(e.detail.data);
        d.update(e.detail.data);
      });

  d.addEventListener('close', () => t.clearSelection());

  return t;
}

function initReportTable<T extends Report>(
    panel: HTMLElement, handler: HandlerInterface,
    cols: Iterable<DataColumn<T>>): AttributionInternalsTableElement<T> {
  const sendReportButton = panel.querySelector('button')!;

  const t = initPanel<T>(
      panel,
      [
        reportStatusColumn,
        valueColumn('URL', 'reportUrl', asUrl),
        valueColumn('Trigger Time', 'triggerTime', asDate),
        valueColumn('Report Time', 'reportTime', asDate, /*defaultSort=*/ true),
        ...cols,
      ],
      {
        // Prevent sent/dropped reports from being removed by returning
        // undefined.
        getId: (report, updated) =>
            (report.isPending() || updated) ? report.id.value : undefined,
        isSelectable: true,
      },
      [valueColumn('Body', 'reportBody', asCode)],
      (report: T|undefined) => sendReportButton.disabled =
          !(report?.isPending()));

  sendReportButton.addEventListener(
      'click', () => sendReport(t, sendReportButton, handler));

  return t;
}

/**
 * Sends the selected report.
 * Disables the button while the report is still being sent.
 * Observer.onReportsChanged and Observer.onSourcesChanged will be called
 * automatically as the report is deleted, so there's no need to manually
 * refresh the data on completion.
 */
function sendReport<T extends Report>(
    t: AttributionInternalsTableElement<T>, sendReportButton: HTMLButtonElement,
    handler: HandlerInterface): void {
  const id = t.selectedData()?.id;
  if (id === undefined) {
    return;
  }

  const previousText = sendReportButton.innerText;

  sendReportButton.disabled = true;
  sendReportButton.innerText = 'Sending...';

  handler.sendReport(id).then(() => {
    sendReportButton.innerText = previousText;
  });
}

const registrationTypeText: Readonly<Record<RegistrationType, string>> = {
  [RegistrationType.kSource]: 'Source',
  [RegistrationType.kTrigger]: 'Trigger',
};

const osRegistrationResultText:
    Readonly<Record<OsRegistrationResult, string>> = {
      [OsRegistrationResult.kPassedToOs]: 'Passed to OS',
      [OsRegistrationResult.kInvalidRegistrationUrl]:
          'Invalid registration URL',
      [OsRegistrationResult.kProhibitedByBrowserPolicy]:
          'Prohibited by browser policy',
      [OsRegistrationResult.kExcessiveQueueSize]: 'Excessive queue size',
      [OsRegistrationResult.kRejectedByOs]: 'Rejected by OS',
    };

interface OsRegistration {
  time: Date;
  registrationUrl: string;
  topLevelOrigin: string;
  registrationType: string;
  debugKeyAllowed: boolean;
  debugReporting: boolean;
  result: string;
}

function newOsRegistration(mojo: WebUIOsRegistration): OsRegistration {
  return {
    time: new Date(mojo.time),
    registrationUrl: mojo.registrationUrl.url,
    topLevelOrigin: originToText(mojo.topLevelOrigin),
    debugKeyAllowed: mojo.isDebugKeyAllowed,
    debugReporting: mojo.debugReporting,
    registrationType: `OS ${registrationTypeText[mojo.type]}`,
    result: osRegistrationResultText[mojo.result],
  };
}

function initOsRegistrationTable(
    t: AttributionInternalsTableElement<OsRegistration>):
    AttributionInternalsTableElement<OsRegistration> {
  t.init([
    valueColumn('Time', 'time', asDate, /*defaultSort=*/ true),
    valueColumn('Type', 'registrationType', asStringOrBool),
    valueColumn('URL', 'registrationUrl', asUrl),
    valueColumn('Top-Level Origin', 'topLevelOrigin', asUrl),
    valueColumn('Debug Key Allowed', 'debugKeyAllowed', asStringOrBool),
    valueColumn('Debug Reporting', 'debugReporting', asStringOrBool),
    valueColumn('Result', 'result', asStringOrBool),
  ]);
  return t;
}

interface DebugReport {
  body: string;
  url: string;
  time: Date;
  status: string;
  sendFailed: boolean;
}

function verboseDebugReport(mojo: WebUIDebugReport): DebugReport {
  const report: DebugReport = {
    body: mojo.body,
    url: mojo.url.url,
    time: new Date(mojo.time),
    status: '',
    sendFailed: false,
  };

  [report.status, report.sendFailed] =
      networkStatusToString(mojo.status, /*sentPrefix=*/ '');

  return report;
}

function attributionSuccessDebugReport(mojo: WebUIReport): DebugReport {
  const [status, sendFailed] =
      Report.statusToString(mojo.status, /*sentPrefix=*/ '');
  return {
    body: mojo.reportBody,
    url: mojo.reportUrl.url,
    time: new Date(mojo.reportTime),
    status,
    sendFailed,
  };
}

const processAggregatableDebugReportResultText:
    Readonly<Record<ProcessAggregatableDebugReportResult, string>> = {
      [ProcessAggregatableDebugReportResult.kSuccess]: 'Success',
      [ProcessAggregatableDebugReportResult.kNoDebugData]: 'No debug data',
      [ProcessAggregatableDebugReportResult.kInsufficientBudget]:
          'Insufficient budget',
      [ProcessAggregatableDebugReportResult.kExcessiveReports]:
          'Excessive reports',
      [ProcessAggregatableDebugReportResult.kGlobalRateLimitReached]:
          'Global rate-limit reached',
      [ProcessAggregatableDebugReportResult.kReportingSiteRateLimitReached]:
          'Per reporting site rate-limit reached',
      [ProcessAggregatableDebugReportResult.kBothRateLimitsReached]:
          'Both rate-limits reached',
      [ProcessAggregatableDebugReportResult.kInternalError]: 'Internal error',
    };

function aggregatableDebugReport(mojo: WebUIAggregatableDebugReport):
    DebugReport {
  const report: DebugReport = {
    body: mojo.body,
    url: mojo.url.url,
    time: new Date(mojo.time),
    status: '',
    sendFailed: false,
  };

  const processStatus =
      processAggregatableDebugReportResultText[mojo.processResult];
  let sendStatus;

  if (mojo.sendResult.networkStatus !== undefined) {
    [sendStatus, report.sendFailed] = networkStatusToString(
        mojo.sendResult.networkStatus, /*sentPrefix=*/ '');
  } else if (mojo.sendResult.assemblyFailed !== undefined) {
    sendStatus = 'Assembly failure';
  } else {
    throw new Error('invalid AggregatableDebugReportStatus union');
  }

  report.status = `${processStatus}, ${sendStatus}`;

  return report;
}

function initDebugReportTable(panel: HTMLElement):
    AttributionInternalsTableElement<DebugReport> {
  return initPanel(
      panel,
      [
        valueColumn('Time', 'time', asDate, /*defaultSort=*/ true),
        valueColumn('URL', 'url', asUrl),
        reportStatusColumn,
      ],
      {isSelectable: true}, [
        valueColumn('Body', 'body', asCode),
      ]);
}

// Converts a mojo origin into a user-readable string, omitting default ports.
function originToText(origin: Origin): string {
  if (origin.host.length === 0) {
    return 'Null';
  }

  let result = origin.scheme + '://' + origin.host;

  if ((origin.scheme === 'https' && origin.port !== 443) ||
      (origin.scheme === 'http' && origin.port !== 80)) {
    result += ':' + origin.port;
  }
  return result;
}

const sourceTypeText: Readonly<Record<SourceType, string>> = {
  [SourceType.kNavigation]: 'Navigation',
  [SourceType.kEvent]: 'Event',
};

const triggerDataMatchingText: Readonly<Record<TriggerDataMatching, string>> = {
  [TriggerDataMatching.kModulus]: 'modulus',
  [TriggerDataMatching.kExact]: 'exact',
};

const attributabilityText:
    Readonly<Record<WebUISource_Attributability, string>> = {
      [WebUISource_Attributability.kAttributable]: 'Attributable',
      [WebUISource_Attributability.kNoisedNever]:
          'Unattributable: noised with no reports',
      [WebUISource_Attributability.kNoisedFalsely]:
          'Unattributable: noised with fake reports',
      [WebUISource_Attributability.kReachedEventLevelAttributionLimit]:
          'Attributable: reached event-level attribution limit',
    };

const sourceRegistrationStatusText:
    Readonly<Record<StoreSourceResult, string>> = {
      [StoreSourceResult.kSuccess]: 'Success',
      [StoreSourceResult.kSuccessNoised]: 'Success',
      [StoreSourceResult.kInternalError]: 'Rejected: internal error',
      [StoreSourceResult.kInsufficientSourceCapacity]:
          'Rejected: insufficient source capacity',
      [StoreSourceResult.kInsufficientUniqueDestinationCapacity]:
          'Rejected: insufficient unique destination capacity',
      [StoreSourceResult.kExcessiveReportingOrigins]:
          'Rejected: excessive reporting origins',
      [StoreSourceResult.kProhibitedByBrowserPolicy]:
          'Rejected: prohibited by browser policy',
      [StoreSourceResult.kDestinationReportingLimitReached]:
          'Rejected: destination reporting limit reached',
      [StoreSourceResult.kDestinationGlobalLimitReached]:
          'Rejected: destination global limit reached',
      [StoreSourceResult.kDestinationBothLimitsReached]:
          'Rejected: destination both limits reached',
      [StoreSourceResult.kExceedsMaxChannelCapacity]:
          'Rejected: channel capacity exceeds max allowed',
      [StoreSourceResult.kReportingOriginsPerSiteLimitReached]:
          'Rejected: reached reporting origins per site limit',
      [StoreSourceResult.kExceedsMaxTriggerStateCardinality]:
          'Rejected: trigger state cardinality exceeds limit',
      [StoreSourceResult.kDestinationPerDayReportingLimitReached]:
          'Rejected: destination per day reporting limit reached',
      [StoreSourceResult.kExceedsMaxScopesChannelCapacity]:
          'Rejected: scopes channel capacity exceeds max allowed',
      [StoreSourceResult.kExceedsMaxEventStatesLimit]:
          'Rejected: event states exceeds limit',
    };

const commonResult = {
  success: 'Success: Report stored',
  internalError: 'Failure: Internal error',
  noMatchingImpressions: 'Failure: No matching sources',
  noMatchingSourceFilterData: 'Failure: No matching source filter data',
  deduplicated: 'Failure: Deduplicated against an earlier report',
  noCapacityForConversionDestination:
      'Failure: No report capacity for destination site',
  excessiveAttributions: 'Failure: Excessive attributions',
  excessiveReportingOrigins: 'Failure: Excessive reporting origins',
  reportWindowPassed: 'Failure: Report window has passed',
  excessiveReports: 'Failure: Excessive reports',
  prohibitedByBrowserPolicy: 'Failure: Prohibited by browser policy',
};

const eventLevelResultText: Readonly<Record<EventLevelResult, string>> = {
  [EventLevelResult.kSuccess]: commonResult.success,
  [EventLevelResult.kSuccessDroppedLowerPriority]: commonResult.success,
  [EventLevelResult.kInternalError]: commonResult.internalError,
  [EventLevelResult.kNoMatchingImpressions]: commonResult.noMatchingImpressions,
  [EventLevelResult.kNoMatchingSourceFilterData]:
      commonResult.noMatchingSourceFilterData,
  [EventLevelResult.kNoCapacityForConversionDestination]:
      commonResult.noCapacityForConversionDestination,
  [EventLevelResult.kExcessiveAttributions]: commonResult.excessiveAttributions,
  [EventLevelResult.kExcessiveReportingOrigins]:
      commonResult.excessiveReportingOrigins,
  [EventLevelResult.kDeduplicated]: commonResult.deduplicated,
  [EventLevelResult.kReportWindowNotStarted]:
      'Failure: Report window has not started',
  [EventLevelResult.kReportWindowPassed]: commonResult.reportWindowPassed,
  [EventLevelResult.kPriorityTooLow]: 'Failure: Priority too low',
  [EventLevelResult.kNeverAttributedSource]: 'Failure: Noised',
  [EventLevelResult.kFalselyAttributedSource]: 'Failure: Noised',
  [EventLevelResult.kNotRegistered]: 'Failure: No event-level data present',
  [EventLevelResult.kProhibitedByBrowserPolicy]:
      commonResult.prohibitedByBrowserPolicy,
  [EventLevelResult.kNoMatchingConfigurations]:
      'Failure: no matching event-level configurations',
  [EventLevelResult.kExcessiveReports]: commonResult.excessiveReports,
  [EventLevelResult.kNoMatchingTriggerData]:
      'Failure: no matching trigger data',
};

const aggregatableResultText: Readonly<Record<AggregatableResult, string>> = {
  [AggregatableResult.kSuccess]: commonResult.success,
  [AggregatableResult.kInternalError]: commonResult.internalError,
  [AggregatableResult.kNoMatchingImpressions]:
      commonResult.noMatchingImpressions,
  [AggregatableResult.kNoMatchingSourceFilterData]:
      commonResult.noMatchingSourceFilterData,
  [AggregatableResult.kNoCapacityForConversionDestination]:
      commonResult.noCapacityForConversionDestination,
  [AggregatableResult.kExcessiveAttributions]:
      commonResult.excessiveAttributions,
  [AggregatableResult.kExcessiveReportingOrigins]:
      commonResult.excessiveReportingOrigins,
  [AggregatableResult.kDeduplicated]: commonResult.deduplicated,
  [AggregatableResult.kReportWindowPassed]: commonResult.reportWindowPassed,
  [AggregatableResult.kNoHistograms]: 'Failure: No source histograms',
  [AggregatableResult.kInsufficientBudget]: 'Failure: Insufficient budget',
  [AggregatableResult.kNotRegistered]: 'Failure: No aggregatable data present',
  [AggregatableResult.kProhibitedByBrowserPolicy]:
      commonResult.prohibitedByBrowserPolicy,
  [AggregatableResult.kExcessiveReports]: commonResult.excessiveReports,
};

const attributionSupportText: Readonly<Record<AttributionSupport, string>> = {
  [AttributionSupport.kWeb]: 'web',
  [AttributionSupport.kWebAndOs]: 'os, web',
  [AttributionSupport.kOs]: 'os',
  [AttributionSupport.kNone]: '',
  [AttributionSupport.kUnset]: 'unset',
};

class AttributionInternals implements ObserverInterface {
  private readonly sources: AttributionInternalsTableElement<Source>;
  private readonly sourceRegistrations:
      AttributionInternalsTableElement<SourceRegistration>;
  private readonly triggers: AttributionInternalsTableElement<Trigger>;
  private readonly debugReports: AttributionInternalsTableElement<DebugReport>;
  private readonly osRegistrations:
      AttributionInternalsTableElement<OsRegistration>;
  private readonly eventLevelReports:
      AttributionInternalsTableElement<EventLevelReport>;
  private readonly aggregatableReports:
      AttributionInternalsTableElement<AggregatableReport>;

  private readonly handler = new HandlerRemote();

  constructor() {
    this.eventLevelReports = initReportTable<EventLevelReport>(
        document.querySelector('#event-level-report-panel')!, this.handler, [
          valueColumn('Priority', 'reportPriority', asNumber),
          valueColumn('Randomized', 'randomizedReport', asStringOrBool),
        ]);

    this.aggregatableReports = initReportTable<AggregatableReport>(
        document.querySelector('#aggregatable-report-panel')!, this.handler, [
          valueColumn('Histograms', 'contributions', asCode),
          valueColumn(
              'Aggregation Coordinator', 'aggregationCoordinator', asUrl),
          valueColumn('Null', 'isNullReport', asStringOrBool),
        ]);

    this.sources =
        initSourceTable(document.querySelector('#active-source-panel')!);

    this.sourceRegistrations = initSourceRegistrationTable(
        document.querySelector('#source-registration-panel')!);

    this.triggers = initTriggerTable(
        document.querySelector('#trigger-registration-panel')!);

    this.debugReports =
        initDebugReportTable(document.querySelector('#debug-report-panel')!);

    this.osRegistrations = initOsRegistrationTable(
        document.querySelector('#osRegistrationTable')!);

    const tabs = document.querySelectorAll<HTMLElement>('div[slot="tab"]');
    const panels = document.querySelectorAll<HTMLElement>('div[slot="panel"]');

    for (let i = 0; i < panels.length && i < tabs.length; ++i) {
      const tab = tabs[i]!;
      panels[i]!.addEventListener(
          'rows-change',
          e => tab.classList.toggle(
              'unread',
              !tab.hasAttribute('selected') && e.detail.rowCount > 0));
    }

    Factory.getRemote().create(
        new ObserverReceiver(this).$.bindNewPipeAndPassRemote(),
        this.handler.$.bindNewPipeAndPassReceiver());
  }

  onReportHandled(mojo: WebUIReport): void {
    this.addSentOrDroppedReport(mojo);
  }

  onDebugReportSent(mojo: WebUIDebugReport): void {
    this.debugReports.addRow(verboseDebugReport(mojo));
  }

  onAggregatableDebugReportSent(mojo: WebUIAggregatableDebugReport): void {
    this.debugReports.addRow(aggregatableDebugReport(mojo));
  }

  onSourceHandled(mojo: WebUISourceRegistration): void {
    this.sourceRegistrations.addRow(new SourceRegistration(mojo));
  }

  onTriggerHandled(mojo: WebUITrigger): void {
    this.triggers.addRow(new Trigger(mojo));
  }

  onOsRegistration(mojo: WebUIOsRegistration): void {
    this.osRegistrations.addRow(newOsRegistration(mojo));
  }

  private addSentOrDroppedReport(mojo: WebUIReport): void {
    if (isAttributionSuccessDebugReport(mojo.reportUrl.url)) {
      this.debugReports.addRow(attributionSuccessDebugReport(mojo));
    } else if (mojo.data.eventLevelData !== undefined) {
      this.eventLevelReports.addRow(new EventLevelReport(mojo));
    } else {
      this.aggregatableReports.addRow(new AggregatableReport(mojo));
    }
  }

  /**
   * Deletes all data stored by the conversions backend.
   * onReportsChanged and onSourcesChanged will be called
   * automatically as data is deleted, so there's no need to manually refresh
   * the data on completion.
   */
  clearStorage(): void {
    this.sourceRegistrations.clearRows();
    this.triggers.clearRows();
    this.eventLevelReports.clearRows(report => !report.isPending());
    this.aggregatableReports.clearRows(report => !report.isPending());
    this.debugReports.clearRows();
    this.osRegistrations.clearRows();
    this.handler.clearStorage();
  }

  onDebugModeChanged(debugMode: boolean): void {
    const reportDelaysContent =
        document.querySelector<HTMLElement>('#report-delays')!;
    const noiseContent = document.querySelector<HTMLElement>('#noise')!;

    if (debugMode) {
      reportDelaysContent.innerText = 'disabled';
      noiseContent.innerText = 'disabled';
    } else {
      reportDelaysContent.innerText = 'enabled';
      noiseContent.innerText = 'enabled';
    }
  }

  refresh(): void {
    this.handler.isAttributionReportingEnabled().then((response) => {
      const featureStatus =
          document.querySelector<HTMLElement>('#feature-status')!;
      featureStatus.innerText = response.enabled ? 'enabled' : 'disabled';

      const attributionSupport = document.querySelector<HTMLElement>('#attribution-support')!;
      attributionSupport.innerText =
          attributionSupportText[response.attributionSupport];
    });
  }

  onSourcesChanged(sources: WebUISource[]): void {
    this.sources.updateRows(function*() {
      for (const source of sources) {
        yield newSource(source);
      }
    }());
  }

  onReportsChanged(reports: WebUIReport[]): void {
    this.eventLevelReports.updateRows(function*() {
      for (const report of reports) {
        if (report.data.eventLevelData !== undefined) {
          yield new EventLevelReport(report);
        }
      }
    }());

    this.aggregatableReports.updateRows(function*() {
      for (const report of reports) {
        if (report.data.aggregatableAttributionData !== undefined) {
          yield new AggregatableReport(report);
        }
      }
    }());
  }
}

document.addEventListener('DOMContentLoaded', function() {
  const tabBox = document.querySelector('cr-tab-box')!;
  tabBox.addEventListener('selected-index-change', e => {
    const tabs = document.querySelectorAll<HTMLElement>('div[slot="tab"]');
    tabs[e.detail]!.classList.remove('unread');
  });

  const internals = new AttributionInternals();

  document.querySelector('#refresh')!.addEventListener(
      'click', () => internals.refresh());
  document.querySelector('#clear-data')!.addEventListener(
      'click', () => internals.clearStorage());

  tabBox.hidden = false;

  internals.refresh();
});