// Copyright 2024 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 * as i18n from '../../core/i18n/i18n.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as EmulationModel from '../../models/emulation/emulation.js';

const UIStrings = {
  /**
   * @description Warning message indicating that the user will see real user data for a URL which is different from the URL they are currently looking at.
   */
  fieldOverrideWarning: 'Field metrics are configured for a different URL than the current page.',
} as const;

const str_ = i18n.i18n.registerUIStrings('models/crux-manager/CrUXManager.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

// This key is expected to be visible in the frontend.
// b/349721878
const CRUX_API_KEY = 'AIzaSyCCSOx25vrb5z0tbedCB3_JRzzbVW6Uwgw';
const DEFAULT_ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${CRUX_API_KEY}`;

export type StandardMetricNames = 'cumulative_layout_shift'|'first_contentful_paint'|'first_input_delay'|
    'interaction_to_next_paint'|'largest_contentful_paint'|'experimental_time_to_first_byte'|'round_trip_time'|
    'largest_contentful_paint_image_time_to_first_byte'|'largest_contentful_paint_image_resource_load_delay'|
    'largest_contentful_paint_image_resource_load_duration'|'largest_contentful_paint_image_element_render_delay';
export type MetricNames = StandardMetricNames|'form_factors';
export type FormFactor = 'DESKTOP'|'PHONE'|'TABLET';
export type DeviceScope = FormFactor|'ALL';
export type DeviceOption = DeviceScope|'AUTO';
export type PageScope = 'url'|'origin';
export interface Scope {
  pageScope: PageScope;
  deviceScope: DeviceScope;
}
export type ConnectionType = 'offline'|'slow-2G'|'2G'|'3G'|'4G';

export interface CrUXRequest {
  effectiveConnectionType?: ConnectionType;
  formFactor?: FormFactor;
  metrics?: MetricNames[];
  origin?: string;
  url?: string;
}

export interface MetricResponse {
  histogram?: Array<{start: number, end?: number, density?: number}>;
  percentiles?: {p75: number|string};
}

export interface FormFactorsResponse {
  fractions?: {
    desktop: number,
    phone: number,
    tablet: number,
  };
}

interface CollectionDate {
  year: number;
  month: number;
  day: number;
}

interface CrUXRecord {
  key: Omit<CrUXRequest, 'metrics'>;
  metrics: Partial<Record<StandardMetricNames, MetricResponse>>&{
    // eslint-disable-next-line @typescript-eslint/naming-convention
    form_factors?: FormFactorsResponse,
  };
  collectionPeriod: {
    firstDate: CollectionDate,
    lastDate: CollectionDate,
  };
}

export interface CrUXResponse {
  record: CrUXRecord;
  urlNormalizationDetails?: {
    originalUrl: string,
    normalizedUrl: string,
  };
}

export type PageResult = Record<`${PageScope}-${DeviceScope}`, CrUXResponse|null>&{
  warnings: string[],
  normalizedUrl: string,
};

export interface OriginMapping {
  developmentOrigin: string;
  productionOrigin: string;
}

export interface ConfigSetting {
  enabled: boolean;
  override?: string;
  overrideEnabled?: boolean;
  originMappings?: OriginMapping[];
}

let cruxManagerInstance: CrUXManager;

/** TODO: Potentially support `TABLET`. Tablet field data will always be `null` until then. **/
export const DEVICE_SCOPE_LIST: DeviceScope[] = ['ALL', 'DESKTOP', 'PHONE'];

const pageScopeList: PageScope[] = ['origin', 'url'];
const metrics: MetricNames[] = [
  'first_contentful_paint',
  'largest_contentful_paint',
  'cumulative_layout_shift',
  'interaction_to_next_paint',
  'round_trip_time',
  'form_factors',
  'largest_contentful_paint_image_time_to_first_byte',
  'largest_contentful_paint_image_resource_load_delay',
  'largest_contentful_paint_image_resource_load_duration',
  'largest_contentful_paint_image_element_render_delay',
];

export class CrUXManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
  #originCache = new Map<string, CrUXResponse|null>();
  #urlCache = new Map<string, CrUXResponse|null>();
  #mainDocumentUrl?: string;
  #configSetting: Common.Settings.Setting<ConfigSetting>;
  #endpoint = DEFAULT_ENDPOINT;
  #pageResult?: PageResult;
  fieldDeviceOption: DeviceOption = 'AUTO';
  fieldPageScope: PageScope = 'url';

  private constructor() {
    super();

    /**
     * In an incognito or guest window - which is called an "OffTheRecord"
     * profile in Chromium -, we do not want to persist the user consent and
     * should ask for it every time. This is why we see what window type the
     * user is in before choosing where to look/create this setting. If the
     * user is in OTR, we store it in the session, which uses sessionStorage
     * and is short-lived. If the user is not in OTR, we use global, which is
     * the default behaviour and persists the value to the Chrome profile.
     * This behaviour has been approved by Chrome Privacy as part of the launch
     * review.
     */
    const useSessionStorage = Root.Runtime.hostConfig.isOffTheRecord === true;
    const storageTypeForConsent =
        useSessionStorage ? Common.Settings.SettingStorageType.SESSION : Common.Settings.SettingStorageType.GLOBAL;

    this.#configSetting = Common.Settings.Settings.instance().createSetting<ConfigSetting>(
        'field-data', {enabled: false, override: '', originMappings: [], overrideEnabled: false},
        storageTypeForConsent);

    this.#configSetting.addChangeListener(() => {
      void this.refresh();
    });

    SDK.TargetManager.TargetManager.instance().addModelListener(
        SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.FrameNavigated, this.#onFrameNavigated,
        this);
  }

  static instance(opts: {forceNew: boolean|null} = {forceNew: null}): CrUXManager {
    const {forceNew} = opts;
    if (!cruxManagerInstance || forceNew) {
      cruxManagerInstance = new CrUXManager();
    }

    return cruxManagerInstance;
  }

  /** The most recent page result from the CrUX service. */
  get pageResult(): PageResult|undefined {
    return this.#pageResult;
  }

  getConfigSetting(): Common.Settings.Setting<ConfigSetting> {
    return this.#configSetting;
  }

  isEnabled(): boolean {
    return this.#configSetting.get().enabled;
  }

  async getFieldDataForPage(pageUrl: string): Promise<PageResult> {
    const pageResult: PageResult = {
      'origin-ALL': null,
      'origin-DESKTOP': null,
      'origin-PHONE': null,
      'origin-TABLET': null,
      'url-ALL': null,
      'url-DESKTOP': null,
      'url-PHONE': null,
      'url-TABLET': null,
      warnings: [],
      normalizedUrl: '',
    };

    try {
      const normalizedUrl = this.#normalizeUrl(pageUrl);
      pageResult.normalizedUrl = normalizedUrl.href;
      const promises: Array<Promise<void>> = [];

      for (const pageScope of pageScopeList) {
        for (const deviceScope of DEVICE_SCOPE_LIST) {
          const promise = this.#getScopedData(normalizedUrl, pageScope, deviceScope).then(response => {
            pageResult[`${pageScope}-${deviceScope}`] = response;
          });
          promises.push(promise);
        }
      }

      await Promise.all(promises);
    } catch (err) {
      console.error(err);
    } finally {
      return pageResult;
    }
  }

  #getMappedUrl(unmappedUrl: string): string {
    try {
      const unmapped = new URL(unmappedUrl);
      const mappings = this.#configSetting.get().originMappings || [];
      const mapping = mappings.find(m => m.developmentOrigin === unmapped.origin);
      if (!mapping) {
        return unmappedUrl;
      }

      const mapped = new URL(mapping.productionOrigin);
      mapped.pathname = unmapped.pathname;

      return mapped.href;
    } catch {
      return unmappedUrl;
    }
  }

  async getFieldDataForCurrentPageForTesting(): Promise<PageResult> {
    return await this.#getFieldDataForCurrentPage();
  }

  /**
   * In general, this function should use the main document URL
   * (i.e. the URL after all redirects but before SPA navigations)
   *
   * However, we can't detect the main document URL of the current page if it's
   * navigation occurred before DevTools was first opened. This function will fall
   * back to the currently inspected URL (i.e. what is displayed in the omnibox) if
   * the main document URL cannot be found.
   */
  async #getFieldDataForCurrentPage(): Promise<PageResult> {
    const currentUrl = this.#mainDocumentUrl || await this.#getInspectedURL();
    const normalizedUrl = this.#configSetting.get().overrideEnabled ? this.#configSetting.get().override || '' :
                                                                      this.#getMappedUrl(currentUrl);

    const result = await this.getFieldDataForPage(normalizedUrl);
    if (currentUrl !== normalizedUrl) {
      result.warnings.push(i18nString(UIStrings.fieldOverrideWarning));
    }
    return result;
  }

  async #getInspectedURL(): Promise<string> {
    const targetManager = SDK.TargetManager.TargetManager.instance();
    let inspectedURL = targetManager.inspectedURL();
    if (!inspectedURL) {
      inspectedURL = await new Promise(resolve => {
        function handler(event: {data: SDK.Target.Target}): void {
          const newInspectedURL = event.data.inspectedURL();
          if (newInspectedURL) {
            resolve(newInspectedURL);
            targetManager.removeEventListener(SDK.TargetManager.Events.INSPECTED_URL_CHANGED, handler);
          }
        }
        targetManager.addEventListener(SDK.TargetManager.Events.INSPECTED_URL_CHANGED, handler);
      });
    }
    return inspectedURL;
  }

  async #onFrameNavigated(event: {data: SDK.ResourceTreeModel.ResourceTreeFrame}): Promise<void> {
    if (!event.data.isPrimaryFrame()) {
      return;
    }

    this.#mainDocumentUrl = event.data.url;

    await this.refresh();
  }

  async refresh(): Promise<void> {
    // This does 2 things:
    // - Tells listeners to clear old data so it isn't shown during a URL transition
    // - Tells listeners to clear old data when field data is disabled.
    this.#pageResult = undefined;
    this.dispatchEventToListeners(Events.FIELD_DATA_CHANGED, undefined);

    if (!this.#configSetting.get().enabled) {
      return;
    }

    this.#pageResult = await this.#getFieldDataForCurrentPage();
    this.dispatchEventToListeners(Events.FIELD_DATA_CHANGED, this.#pageResult);
  }

  #normalizeUrl(inputUrl: string): URL {
    const normalizedUrl = new URL(inputUrl);
    normalizedUrl.hash = '';
    normalizedUrl.search = '';
    return normalizedUrl;
  }

  async #getScopedData(normalizedUrl: URL, pageScope: PageScope, deviceScope: DeviceScope): Promise<CrUXResponse|null> {
    const {origin, href: url, hostname} = normalizedUrl;

    if (hostname === 'localhost' || hostname === '127.0.0.1' || !origin.startsWith('http')) {
      return null;
    }

    const cache = pageScope === 'origin' ? this.#originCache : this.#urlCache;
    const cacheKey = pageScope === 'origin' ? `${origin}-${deviceScope}` : `${url}-${deviceScope}`;
    const cachedResponse = cache.get(cacheKey);
    if (cachedResponse !== undefined) {
      return cachedResponse;
    }

    // We shouldn't cache the result in the case of an error
    // The error could be a transient issue with the network/CrUX server/etc.
    try {
      const formFactor = deviceScope === 'ALL' ? undefined : deviceScope;
      const result = pageScope === 'origin' ? await this.#makeRequest({origin, metrics, formFactor}) :
                                              await this.#makeRequest({url, metrics, formFactor});
      cache.set(cacheKey, result);
      return result;
    } catch (err) {
      console.error(err);
      return null;
    }
  }

  async #makeRequest(request: CrUXRequest): Promise<CrUXResponse|null> {
    const body = JSON.stringify(request);
    const response = await fetch(this.#endpoint, {
      method: 'POST',
      body,
    });

    if (!response.ok && response.status !== 404) {
      throw new Error(`Failed to fetch data from CrUX server (Status code: ${response.status})`);
    }

    const responseData = await response.json();
    if (response.status === 404) {
      // This is how CrUX tells us that there is not data available for the provided url/origin
      // Since it's a valid response, just return null instead of throwing an error.
      if (responseData?.error?.status === 'NOT_FOUND') {
        return null;
      }

      throw new Error(`Failed to fetch data from CrUX server (Status code: ${response.status})`);
    }

    if (!('record' in responseData)) {
      throw new Error(`Failed to find data in CrUX response: ${JSON.stringify(responseData)}`);
    }

    return responseData;
  }

  #getAutoDeviceScope(): DeviceScope {
    const emulationModel = EmulationModel.DeviceModeModel.DeviceModeModel.tryInstance();
    if (emulationModel === null) {
      return 'ALL';
    }

    if (emulationModel.isMobile()) {
      if (this.#pageResult?.[`${this.fieldPageScope}-PHONE`]) {
        return 'PHONE';
      }

      return 'ALL';
    }

    if (this.#pageResult?.[`${this.fieldPageScope}-DESKTOP`]) {
      return 'DESKTOP';
    }

    return 'ALL';
  }

  resolveDeviceOptionToScope(option: DeviceOption): DeviceScope {
    return option === 'AUTO' ? this.#getAutoDeviceScope() : option;
  }

  getSelectedDeviceScope(): DeviceScope {
    return this.resolveDeviceOptionToScope(this.fieldDeviceOption);
  }

  getSelectedScope(): Scope {
    return {pageScope: this.fieldPageScope, deviceScope: this.getSelectedDeviceScope()};
  }

  getSelectedFieldResponse(): CrUXResponse|null|undefined {
    const pageScope = this.fieldPageScope;
    const deviceScope = this.getSelectedDeviceScope();
    return this.getFieldResponse(pageScope, deviceScope);
  }

  getSelectedFieldMetricData(fieldMetric: StandardMetricNames): MetricResponse|undefined {
    return this.getSelectedFieldResponse()?.record.metrics[fieldMetric];
  }

  getFieldResponse(pageScope: PageScope, deviceScope: DeviceScope): CrUXResponse|null|undefined {
    return this.#pageResult?.[`${pageScope}-${deviceScope}`];
  }

  setEndpointForTesting(endpoint: string): void {
    this.#endpoint = endpoint;
  }
}

export const enum Events {
  FIELD_DATA_CHANGED = 'field-data-changed',
}

interface EventTypes {
  [Events.FIELD_DATA_CHANGED]: PageResult|undefined;
}
