// Copyright 2025 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 * as Platform from '../../../core/platform/platform.js';
import * as Handlers from '../handlers/handlers.js';
import * as Helpers from '../helpers/helpers.js';
import type * as Lantern from '../lantern/lantern.js';
import type * as Types from '../types/types.js';

import {
  InsightCategory,
  InsightKeys,
  type InsightModel,
  type InsightSetContext,
  type MetricSavings,
  type PartialInsightModel,
} from './types.js';

export const UIStrings = {
  /**
   * @description Title of an insight that recommends using HTTP/2 over HTTP/1.1 because of the performance benefits. "HTTP" should not be translated.
   */
  title: 'Modern HTTP',
  /**
   * @description Description of an insight that recommends recommends using HTTP/2 over HTTP/1.1 because of the performance benefits. "HTTP" should not be translated.
   */
  description:
      'HTTP/2 and HTTP/3 offer many benefits over HTTP/1.1, such as multiplexing. [Learn more about using modern HTTP](https://developer.chrome.com/docs/performance/insights/modern-http).',
  /**
   * @description Column header for a table where each cell represents a network request.
   */
  request: 'Request',
  /**
   * @description Column header for a table where each cell represents the protocol of a network request.
   */
  protocol: 'Protocol',
  /**
   * @description Text explaining that there were not requests that were slowed down by using HTTP/1.1. "HTTP/1.1" should not be translated.
   */
  noOldProtocolRequests:
      'No requests used HTTP/1.1, or its current use of HTTP/1.1 does not present a significant optimization opportunity. HTTP/1.1 requests are only flagged if six or more static assets originate from the same origin, and they are not served from a local development environment or a third-party source.'
} as const;

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

export type ModernHTTPInsightModel = InsightModel<typeof UIStrings, {
  http1Requests: Types.Events.SyntheticNetworkRequest[],
}>;

export function isModernHTTPInsight(model: InsightModel): model is ModernHTTPInsightModel {
  return model.insightKey === InsightKeys.MODERN_HTTP;
}

/**
 * Determines whether a network request is a "static resource" that would benefit from H2 multiplexing.
 * XHRs, tracking pixels, etc generally don't benefit as much because they aren't requested en-masse
 * for the same origin at the exact same time.
 */
function isMultiplexableStaticAsset(
    request: Types.Events.SyntheticNetworkRequest, entityMappings: Handlers.Helpers.EntityMappings,
    firstPartyEntity: Handlers.Helpers.Entity|null): boolean {
  if (!Helpers.Network.STATIC_RESOURCE_TYPES.has(request.args.data.resourceType)) {
    return false;
  }

  // Resources from third-parties that are less than 100 bytes are usually tracking pixels, not actual resources.
  // They can masquerade as static types though (gifs, documents, etc)
  if (request.args.data.decodedBodyLength < 100) {
    const entity = entityMappings.entityByEvent.get(request);
    if (entity) {
      // Third-party assets are multiplexable in their first-party context.
      if (firstPartyEntity?.name === entity.name) {
        return true;
      }
      // Skip recognizable third-parties' requests.
      if (!entity.isUnrecognized) {
        return false;
      }
    }
  }

  return true;
}

/**
 * Determine the set of resources that aren't HTTP/2 but should be.
 * We're a little conservative about what we surface for a few reasons:
 *
 *    - The simulator approximation of HTTP/2 is a little more generous than reality.
 *    - There's a bit of debate surrounding HTTP/2 due to its worse performance in environments with high packet loss. [1][2][3]
 *    - It's something that you'd have absolutely zero control over with a third-party (can't defer to fix it for example).
 *
 * Therefore, we only surface requests that were...
 *
 *    - Served over HTTP/1.1 or earlier
 *    - Served over an origin that serves at least 6 static asset requests
 *      (if there aren't more requests than browser's max/host, multiplexing isn't as big a deal)
 *    - Not served on localhost (h2 is a pain to deal with locally & and CI)
 *
 * [1] https://news.ycombinator.com/item?id=19086639
 * [2] https://www.twilio.com/blog/2017/10/http2-issues.html
 * [3] https://www.cachefly.com/http-2-is-not-a-magic-bullet/
 */
export function determineHttp1Requests(
    requests: Types.Events.SyntheticNetworkRequest[], entityMappings: Handlers.Helpers.EntityMappings,
    firstPartyEntity: Handlers.Helpers.Entity|null): Types.Events.SyntheticNetworkRequest[] {
  const http1Requests: Types.Events.SyntheticNetworkRequest[] = [];

  const groupedByOrigin = new Map<string, Types.Events.SyntheticNetworkRequest[]>();
  for (const record of requests) {
    const url = new URL(record.args.data.url);
    if (!isMultiplexableStaticAsset(record, entityMappings, firstPartyEntity)) {
      continue;
    }
    if (Helpers.Network.isSyntheticNetworkRequestLocalhost(record)) {
      continue;
    }
    const originRequests = Platform.MapUtilities.getWithDefault(groupedByOrigin, url.origin, () => []);
    originRequests.push(record);
  }

  const seenURLs = new Set<string>();

  for (const request of requests) {
    // Skip duplicates.
    if (seenURLs.has(request.args.data.url)) {
      continue;
    }

    // Check if record is not served through the service worker, servicer worker uses http/1.1 as a protocol.
    // These can generate false positives (bug: https://github.com/GoogleChrome/lighthouse/issues/7158).
    if (request.args.data.fromServiceWorker) {
      continue;
    }

    // Test the protocol to see if it was http/1.1.
    const isOldHttp = /HTTP\/[01][.\d]?/i.test(request.args.data.protocol);
    if (!isOldHttp) {
      continue;
    }

    const url = new URL(request.args.data.url);

    // Check if the origin has enough requests to bother flagging.
    const group = groupedByOrigin.get(url.origin) || [];
    if (group.length < 6) {
      continue;
    }

    seenURLs.add(request.args.data.url);
    http1Requests.push(request);
  }

  return http1Requests;
}

/**
 * Computes the estimated effect of all results being converted to http/2 on the provided graph.
 */
function computeWasteWithGraph(
    urlsToChange: Set<string>, graph: Lantern.Graph.Node, simulator: Lantern.Simulation.Simulator): Types.Timing.Milli {
  const simulationBefore = simulator.simulate(graph);

  // Update all the protocols to reflect implementing our recommendations
  const originalProtocols = new Map();
  graph.traverse(node => {
    if (node.type !== 'network') {
      return;
    }
    if (!urlsToChange.has(node.request.url)) {
      return;
    }

    originalProtocols.set(node.request.requestId, node.request.protocol);
    node.request.protocol = 'h2';
  });

  const simulationAfter = simulator.simulate(graph);

  // Restore the original protocol after we've done our simulation
  graph.traverse(node => {
    if (node.type !== 'network') {
      return;
    }
    const originalProtocol = originalProtocols.get(node.request.requestId);
    if (originalProtocol === undefined) {
      return;
    }
    node.request.protocol = originalProtocol;
  });

  const savings = simulationBefore.timeInMs - simulationAfter.timeInMs;

  return Platform.NumberUtilities.floor(savings, 1 / 10) as Types.Timing.Milli;
}

function computeMetricSavings(
    http1Requests: Types.Events.SyntheticNetworkRequest[], context: InsightSetContext): MetricSavings|undefined {
  if (!context.navigation || !context.lantern) {
    return;
  }

  const urlsToChange = new Set(http1Requests.map(r => r.args.data.url));

  const fcpGraph = context.lantern.metrics.firstContentfulPaint.optimisticGraph;
  const lcpGraph = context.lantern.metrics.largestContentfulPaint.optimisticGraph;

  return {
    FCP: computeWasteWithGraph(urlsToChange, fcpGraph, context.lantern.simulator),
    LCP: computeWasteWithGraph(urlsToChange, lcpGraph, context.lantern.simulator),
  };
}

function finalize(partialModel: PartialInsightModel<ModernHTTPInsightModel>): ModernHTTPInsightModel {
  return {
    insightKey: InsightKeys.MODERN_HTTP,
    strings: UIStrings,
    title: i18nString(UIStrings.title),
    description: i18nString(UIStrings.description),
    docs: 'https://developer.chrome.com/docs/performance/insights/modern-http',
    category: InsightCategory.LCP,
    state: partialModel.http1Requests.length > 0 ? 'fail' : 'pass',
    ...partialModel,
    relatedEvents: partialModel.http1Requests,
  };
}

export function generateInsight(data: Handlers.Types.HandlerData, context: InsightSetContext): ModernHTTPInsightModel {
  const isWithinContext = (event: Types.Events.Event): boolean => Helpers.Timing.eventIsInBounds(event, context.bounds);

  const contextRequests = data.NetworkRequests.byTime.filter(isWithinContext);

  const entityMappings = data.NetworkRequests.entityMappings;
  const firstPartyUrl = context.navigation?.args.data?.documentLoaderURL ?? data.Meta.mainFrameURL;
  const firstPartyEntity = Handlers.Helpers.getEntityForUrl(firstPartyUrl, entityMappings);
  const http1Requests = determineHttp1Requests(contextRequests, entityMappings, firstPartyEntity ?? null);

  return finalize({
    http1Requests,
    metricSavings: computeMetricSavings(http1Requests, context),
  });
}

export function createOverlayForRequest(request: Types.Events.SyntheticNetworkRequest): Types.Overlays.EntryOutline {
  return {
    type: 'ENTRY_OUTLINE',
    entry: request,
    outlineReason: 'ERROR',
  };
}

export function createOverlays(model: ModernHTTPInsightModel): Types.Overlays.Overlay[] {
  return model.http1Requests.map(req => createOverlayForRequest(req)) ?? [];
}
