// Copyright 2016 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 '../common/common.js';
import * as i18n from '../i18n/i18n.js';

import type {NameValue} from './NetworkRequest.js';

const UIStrings = {
  /**
   * @description Text in Server Timing
   * @example {sql-lookup} PH1
   */
  deprecatedSyntaxFoundPleaseUse:
      'Deprecated syntax found for metric "{PH1}". Please use: <name>;dur=<duration>;desc=<description>',
  /**
   * @description Text in Server Timing
   * @example {https} PH1
   */
  duplicateParameterSIgnored: 'Duplicate parameter "{PH1}" ignored.',
  /**
   * @description Text in Server Timing
   * @example {https} PH1
   */
  noValueFoundForParameterS: 'No value found for parameter "{PH1}".',
  /**
   * @description Text in Server Timing
   * @example {https} PH1
   */
  unrecognizedParameterS: 'Unrecognized parameter "{PH1}".',
  /**
   * @description Text in Server Timing
   */
  extraneousTrailingCharacters: 'Extraneous trailing characters.',
  /**
   * @description Text in Server Timing
   * @example {https} PH1
   * @example {2.0} PH2
   */
  unableToParseSValueS: 'Unable to parse "{PH1}" value "{PH2}".',
} as const;
const str_ = i18n.i18n.registerUIStrings('core/sdk/ServerTiming.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

/**
 * Represents an authored single server timing metric. https://w3c.github.io/server-timing/#the-server-timing-header-field
 */
export interface ServerTimingMetric {
  /** The name of the metric, a single token */
  name: string;
  /** A human-readable description of the metric. */
  desc?: string;
  /** The duration; milliseconds is recommended. https://w3c.github.io/server-timing/#duration-attribute. */
  dur?: number;
}

export const cloudflarePrefix = '(cf) ';
export const cloudinaryPrefix = '(cld) ';

export class ServerTiming {
  metric: string;
  value: number|null;
  description: string|null;

  constructor(metric: string, value: number|null, description: string|null) {
    this.metric = metric;
    this.value = value;
    this.description = description;
  }

  static parseHeaders(headers: NameValue[]): ServerTiming[]|null {
    const rawServerTimingHeaders = headers.filter(item => item.name.toLowerCase() === 'server-timing');
    if (!rawServerTimingHeaders.length) {
      return null;
    }

    const serverTimings = rawServerTimingHeaders.reduce<ServerTiming[]>((timings, header) => {
      const timing = this.createFromHeaderValue(header.value);
      timings.push(...timing.map(function(entry) {
        return new ServerTiming(entry.name, entry.dur ?? null, entry.desc ?? '');
      }));
      return timings;
    }, []);
    return serverTimings;
  }

  static createFromHeaderValue(valueString: string): ServerTimingMetric[] {
    function trimLeadingWhiteSpace(): void {
      valueString = valueString.replace(/^\s*/, '');
    }
    function consumeDelimiter(char: string): boolean {
      console.assert(char.length === 1);
      trimLeadingWhiteSpace();
      if (valueString.charAt(0) !== char) {
        return false;
      }

      valueString = valueString.substring(1);
      return true;
    }
    function consumeToken(): string|null {
      // https://tools.ietf.org/html/rfc7230#appendix-B
      const result = /^(?:\s*)([\w!#$%&'*+\-.^`|~]+)(?:\s*)(.*)/.exec(valueString);
      if (!result) {
        return null;
      }

      valueString = result[2];
      return result[1];
    }
    function consumeTokenOrQuotedString(): string|null {
      trimLeadingWhiteSpace();
      if (valueString.charAt(0) === '"') {
        return consumeQuotedString();
      }

      return consumeToken();
    }
    function consumeQuotedString(): string|null {
      console.assert(valueString.charAt(0) === '"');
      valueString = valueString.substring(1);  // remove leading DQUOTE

      let value = '';
      while (valueString.length) {
        // split into two parts:
        //  -everything before the first " or \
        //  -everything else
        const result = /^([^"\\]*)(.*)/.exec(valueString);
        if (!result) {
          return null;  // not a valid quoted-string
        }
        value += result[1];
        if (result[2].charAt(0) === '"') {
          // we have found our closing "
          valueString = result[2].substring(1);  // strip off everything after the closing "
          return value;                          // we are done here
        }

        console.assert(result[2].charAt(0) === '\\');
        // special rules for \ found in quoted-string (https://tools.ietf.org/html/rfc7230#section-3.2.6)
        value += result[2].charAt(1);          // grab the character AFTER the \ (if there was one)
        valueString = result[2].substring(2);  // strip off \ and next character
      }

      return null;  // not a valid quoted-string
    }
    function consumeExtraneous(): void {
      const result = /([,;].*)/.exec(valueString);
      if (result) {
        valueString = result[1];
      }
    }

    const result: ServerTimingMetric[] = [];
    let name;
    while ((name = consumeToken()) !== null) {
      const entry: ServerTimingMetric = {name};

      if (valueString.charAt(0) === '=') {
        this.showWarning(i18nString(UIStrings.deprecatedSyntaxFoundPleaseUse, {PH1: name}));
      }

      while (consumeDelimiter(';')) {
        let paramName;
        if ((paramName = consumeToken()) === null) {
          continue;
        }

        paramName = paramName.toLowerCase();
        const parseParameter = this.getParserForParameter(paramName);
        let paramValue: (string|null)|null = null;
        if (consumeDelimiter('=')) {
          // always parse the value, even if we don't recognize the parameter #name
          paramValue = consumeTokenOrQuotedString();
          consumeExtraneous();
        }

        if (parseParameter) {
          // paramName is valid
          if (entry.hasOwnProperty(paramName)) {
            this.showWarning(i18nString(UIStrings.duplicateParameterSIgnored, {PH1: paramName}));
            continue;
          }

          if (paramValue === null) {
            this.showWarning(i18nString(UIStrings.noValueFoundForParameterS, {PH1: paramName}));
          }

          parseParameter.call(this, entry, paramValue);
        } else {
          // paramName is not valid
          // TODO(paulirish): consider showing other included params, like `start`: https://github.com/w3c/server-timing/issues/43
          this.showWarning(i18nString(UIStrings.unrecognizedParameterS, {PH1: paramName}));
        }
      }

      result.push(entry);

      // Special parsing for cloudflare's bespoke format. https://blog.cloudflare.com/new-standards/#measuring-impact
      // We extract the individual items of the cfL4 server-timing for clear presentation
      if (entry.name === 'cfL4' && entry.desc) {
        new URLSearchParams(entry.desc).entries().forEach(([key, val]) => {
          result.push({name: `${cloudflarePrefix}${key}`, desc: val});
        });
      }

      // Special parsing for cloudinary's bespoke format. https://cloudinary.com/blog/inside_the_black_box_with_server_timing#what_details_are_you_sharing_
      // The format has changed since this blog post
      if (entry.name === 'content-info' && entry.desc) {
        new URLSearchParams(entry.desc.replace(/,/g, '&')).entries().forEach(([key, val]) => {
          result.push({name: `${cloudinaryPrefix}${key}`, desc: val});
        });
      }

      if (!consumeDelimiter(',')) {
        break;
      }
    }

    if (valueString.length) {
      this.showWarning(i18nString(UIStrings.extraneousTrailingCharacters));
    }
    return result;
  }

  static getParserForParameter(paramName: string): ((arg0: ServerTimingMetric, arg1: string|null) => void)|null {
    switch (paramName) {
      case 'dur': {
        function durParser(entry: ServerTimingMetric, paramValue: string|null): void {
          entry.dur = 0;
          if (paramValue !== null) {
            const duration = parseFloat(paramValue);
            if (isNaN(duration)) {
              ServerTiming.showWarning(i18nString(UIStrings.unableToParseSValueS, {PH1: paramName, PH2: paramValue}));
              return;
            }
            entry.dur = duration;
          }
        }
        return durParser;
      }

      case 'desc': {
        function descParser(entry: ServerTimingMetric, paramValue: string|null): void {
          entry.desc = paramValue || '';
        }
        return descParser;
      }

      default: {
        return null;
      }
    }
  }

  static showWarning(msg: string): void {
    Common.Console.Console.instance().warn(`ServerTiming: ${msg}`);
  }
}
