// 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 i18n from '../../../core/i18n/i18n.js';
import type * as Handlers from '../handlers/handlers.js';
import * as Helpers from '../helpers/helpers.js';
import * as Types from '../types/types.js';

import {isRequestCompressed} from './Common.js';
import {
  type Checklist,
  InsightCategory,
  InsightKeys,
  type InsightModel,
  type InsightSetContext,
  InsightWarning,
  type PartialInsightModel,
} from './types.js';

export const UIStrings = {
  /**
   * @description Title of an insight that provides a breakdown for how long it took to download the main document.
   */
  title: 'Document request latency',
  /**
   * @description Description of an insight that provides a breakdown for how long it took to download the main document.
   */
  description:
      'Your first network request is the most important. [Reduce its latency](https://developer.chrome.com/docs/performance/insights/document-latency) by avoiding redirects, ensuring a fast server response, and enabling text compression.',
  /**
   * @description Text to tell the user that the document request does not have redirects.
   */
  passingRedirects: 'Avoids redirects',
  /**
   * @description Text to tell the user that the document request had redirects.
   * @example {3} PH1
   * @example {1000 ms} PH2
   */
  failedRedirects: 'Had redirects ({PH1} redirects, +{PH2})',
  /**
   * @description Text to tell the user that the time starting the document request to when the server started responding is acceptable.
   * @example {600 ms} PH1
   */
  passingServerResponseTime: 'Server responds quickly (observed {PH1})',
  /**
   * @description Text to tell the user that the time starting the document request to when the server started responding is not acceptable.
   * @example {601 ms} PH1
   */
  failedServerResponseTime: 'Server responded slowly (observed {PH1})',
  /**
   * @description Text to tell the user that text compression (like gzip) was applied.
   */
  passingTextCompression: 'Applies text compression',
  /**
   * @description Text to tell the user that text compression (like gzip) was not applied.
   */
  failedTextCompression: 'No compression applied',
  /**
   * @description Text for a label describing a network request event as having redirects.
   */
  redirectsLabel: 'Redirects',
  /**
   * @description Text for a label describing a network request event as taking too long to start delivery by the server.
   */
  serverResponseTimeLabel: 'Server response time',
  /**
   * @description Text for a label describing a network request event as taking longer to download because it wasn't compressed.
   */
  uncompressedDownload: 'Uncompressed download',
} as const;

const str_ = i18n.i18n.registerUIStrings('models/trace/insights/DocumentLatency.ts', UIStrings);
export const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

// Due to the way that DevTools throttling works we cannot see if server response took less than ~570ms.
// We set our failure threshold to 600ms to avoid those false positives but we want devs to shoot for 100ms.
const TOO_SLOW_THRESHOLD_MS = 600;
const TARGET_MS = 100;

// Threshold for compression savings.
const IGNORE_THRESHOLD_IN_BYTES = 1400;

export function isDocumentLatencyInsight(x: InsightModel): x is DocumentLatencyInsightModel {
  return x.insightKey === 'DocumentLatency';
}

export type DocumentLatencyInsightModel = InsightModel<typeof UIStrings, {
  data?: {
    serverResponseTime: Types.Timing.Milli,
    redirectDuration: Types.Timing.Milli,
    uncompressedResponseBytes: number,
    checklist: Checklist<'noRedirects'|'serverResponseIsFast'|'usesCompression'>,
    documentRequest?: Types.Events.SyntheticNetworkRequest,
  },
}>;

function getServerResponseTime(request: Types.Events.SyntheticNetworkRequest): Types.Timing.Milli|null {
  // For technical reasons, Lightrider does not have `sendEnd` timing values. The
  // closest we can get to the server response time is from a header that Lightrider
  // sets.
  // @ts-expect-error
  const isLightrider = globalThis.isLightrider;
  if (isLightrider) {
    return request.args.data.lrServerResponseTime ?? null;
  }

  const timing = request.args.data.timing;
  if (!timing) {
    return null;
  }

  const ms = Helpers.Timing.microToMilli(request.args.data.syntheticData.serverResponseTime);
  return Math.round(ms) as Types.Timing.Milli;
}

function getCompressionSavings(request: Types.Events.SyntheticNetworkRequest): number {
  const isCompressed = isRequestCompressed(request);
  if (isCompressed) {
    return 0;
  }

  // We don't know how many bytes this asset used on the network, but we can guess it was
  // roughly the size of the content gzipped.
  // See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/optimize-encoding-and-transfer for specific CSS/Script examples
  // See https://discuss.httparchive.org/t/file-size-and-compression-savings/145 for fallback multipliers
  // See https://letstalkaboutwebperf.com/en/gzip-brotli-server-config/ for MIME types to compress
  const originalSize = request.args.data.decodedBodyLength;
  let estimatedSavings = 0;
  switch (request.args.data.mimeType) {
    case 'text/css':
      // Stylesheets tend to compress extremely well.
      estimatedSavings = Math.round(originalSize * 0.8);
      break;
    case 'text/html':
    case 'text/javascript':
      // Scripts and HTML compress fairly well too.
      estimatedSavings = Math.round(originalSize * 0.67);
      break;
    case 'text/plain':
    case 'text/xml':
    case 'text/x-component':
    case 'application/javascript':
    case 'application/json':
    case 'application/manifest+json':
    case 'application/vnd.api+json':
    case 'application/xml':
    case 'application/xhtml+xml':
    case 'application/rss+xml':
    case 'application/atom+xml':
    case 'application/vnd.ms-fontobject':
    case 'application/x-font-ttf':
    case 'application/x-font-opentype':
    case 'application/x-font-truetype':
    case 'image/svg+xml':
    case 'image/x-icon':
    case 'image/vnd.microsoft.icon':
    case 'font/ttf':
    case 'font/eot':
    case 'font/otf':
    case 'font/opentype':
      // Use the average savings in HTTPArchive.
      estimatedSavings = Math.round(originalSize * 0.5);
      break;
    default:  // Any other MIME types are likely already compressed.
  }
  // Check if the estimated savings are greater than the byte ignore threshold.
  // Note that the estimated gzip savings are always more than 10%, so there is
  // no percent threshold.
  return estimatedSavings < IGNORE_THRESHOLD_IN_BYTES ? 0 : estimatedSavings;
}

function finalize(partialModel: PartialInsightModel<DocumentLatencyInsightModel>): DocumentLatencyInsightModel {
  let hasFailure = false;
  if (partialModel.data) {
    hasFailure = !partialModel.data.checklist.usesCompression.value ||
        !partialModel.data.checklist.serverResponseIsFast.value || !partialModel.data.checklist.noRedirects.value;
  }

  return {
    insightKey: InsightKeys.DOCUMENT_LATENCY,
    strings: UIStrings,
    title: i18nString(UIStrings.title),
    description: i18nString(UIStrings.description),
    docs: 'https://developer.chrome.com/docs/performance/insights/document-latency',
    category: InsightCategory.ALL,
    state: hasFailure ? 'fail' : 'pass',
    ...partialModel,
  };
}

export function generateInsight(
    data: Handlers.Types.HandlerData, context: InsightSetContext): DocumentLatencyInsightModel {
  if (!context.navigation) {
    return finalize({});
  }

  const millisToString = context.options.insightTimeFormatters?.milli ?? i18n.TimeUtilities.millisToString;

  const documentRequest = data.NetworkRequests.byId.get(context.navigationId);
  if (!documentRequest) {
    return finalize({warnings: [InsightWarning.NO_DOCUMENT_REQUEST]});
  }

  const serverResponseTime = getServerResponseTime(documentRequest);
  if (serverResponseTime === null) {
    throw new Error('missing document request timing');
  }

  const serverResponseTooSlow = serverResponseTime > TOO_SLOW_THRESHOLD_MS;

  let overallSavingsMs = 0;
  if (serverResponseTime > TOO_SLOW_THRESHOLD_MS) {
    overallSavingsMs = Math.max(serverResponseTime - TARGET_MS, 0);
  }

  const redirectDuration =
      Math.round(documentRequest.args.data.syntheticData.redirectionDuration / 1000) as Types.Timing.Milli;
  overallSavingsMs += redirectDuration;

  const metricSavings = {
    FCP: overallSavingsMs as Types.Timing.Milli,
    LCP: overallSavingsMs as Types.Timing.Milli,
  };

  const uncompressedResponseBytes = getCompressionSavings(documentRequest);

  const noRedirects = redirectDuration === 0;
  const serverResponseIsFast = !serverResponseTooSlow;
  const usesCompression = uncompressedResponseBytes === 0;

  return finalize({
    relatedEvents: [documentRequest],
    data: {
      serverResponseTime,
      redirectDuration: Types.Timing.Milli(redirectDuration),
      uncompressedResponseBytes,
      documentRequest,
      checklist: {
        noRedirects: {
          label: noRedirects ? i18nString(UIStrings.passingRedirects) : i18nString(UIStrings.failedRedirects, {
            PH1: documentRequest.args.data.redirects.length,
            PH2: millisToString(redirectDuration),
          }),
          value: noRedirects
        },
        serverResponseIsFast: {
          label: serverResponseIsFast ?
              i18nString(UIStrings.passingServerResponseTime, {PH1: millisToString(serverResponseTime)}) :
              i18nString(UIStrings.failedServerResponseTime, {PH1: millisToString(serverResponseTime)}),
          value: serverResponseIsFast
        },
        usesCompression: {
          label: usesCompression ? i18nString(UIStrings.passingTextCompression) :
                                   i18nString(UIStrings.failedTextCompression),
          value: usesCompression
        },
      },
    },
    metricSavings,
    wastedBytes: uncompressedResponseBytes,
  });
}

export function createOverlays(model: DocumentLatencyInsightModel): Types.Overlays.Overlay[] {
  if (!model.data?.documentRequest) {
    return [];
  }

  const overlays: Types.Overlays.Overlay[] = [];
  const event = model.data.documentRequest;
  const redirectDurationMicro = Helpers.Timing.milliToMicro(model.data.redirectDuration);

  const sections = [];
  if (model.data.redirectDuration) {
    const bounds = Helpers.Timing.traceWindowFromMicroSeconds(
        event.ts,
        (event.ts + redirectDurationMicro) as Types.Timing.Micro,
    );
    sections.push({bounds, label: i18nString(UIStrings.redirectsLabel), showDuration: true});
    overlays.push({type: 'CANDY_STRIPED_TIME_RANGE', bounds, entry: event});
  }
  if (!model.data.checklist.serverResponseIsFast.value) {
    const serverResponseTimeMicro = Helpers.Timing.milliToMicro(model.data.serverResponseTime);
    // NOTE: NetworkRequestHandlers never makes a synthetic network request event if `timing` is missing.
    const sendEnd = event.args.data.timing?.sendEnd ?? Types.Timing.Milli(0);
    const sendEndMicro = Helpers.Timing.milliToMicro(sendEnd);
    const bounds = Helpers.Timing.traceWindowFromMicroSeconds(
        sendEndMicro,
        (sendEndMicro + serverResponseTimeMicro) as Types.Timing.Micro,
    );
    sections.push({bounds, label: i18nString(UIStrings.serverResponseTimeLabel), showDuration: true});
  }
  if (model.data.uncompressedResponseBytes) {
    const bounds = Helpers.Timing.traceWindowFromMicroSeconds(
        event.args.data.syntheticData.downloadStart,
        (event.args.data.syntheticData.downloadStart + event.args.data.syntheticData.download) as Types.Timing.Micro,
    );
    sections.push({bounds, label: i18nString(UIStrings.uncompressedDownload), showDuration: true});
    overlays.push({type: 'CANDY_STRIPED_TIME_RANGE', bounds, entry: event});
  }

  if (sections.length) {
    overlays.push({
      type: 'TIMESPAN_BREAKDOWN',
      sections,
      entry: model.data.documentRequest,
      // Always render below because the document request is guaranteed to be
      // the first request in the network track.
      renderLocation: 'BELOW_EVENT',
    });
  }
  overlays.push({
    type: 'ENTRY_SELECTED',
    entry: model.data.documentRequest,
  });

  return overlays;
}
