// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import m from 'mithril';
import {Time} from '../base/time';
import {showModal} from '../widgets/modal';
import {initCssConstants} from './css_constants';
import {toggleHelp} from './help_modal';
import {AppImpl} from '../core/app_impl';
import type {SerializedAppState} from '../core/state_serialization_schema';
import {parseAppState} from '../core/state_serialization';
import {BUCKET_NAME, isValidGcsFileName} from '../base/gcs_uploader';

const TRUSTED_ORIGINS_KEY = 'trustedOrigins';

interface PostedTrace {
  buffer: ArrayBuffer;
  title: string;
  fileName?: string;
  url?: string;

  // The hash of the app state to load from GCS after the trace is loaded
  appStateHash?: string;

  // if |localOnly| is true then the trace should not be shared or downloaded.
  localOnly?: boolean;
  keepApiOpen?: boolean;

  // Allows to pass extra arguments to plugins. This can be read by plugins
  // onTraceLoad() and can be used to trigger plugin-specific-behaviours (e.g.
  // allow dashboards like APC to pass extra data to materialize onto tracks).
  // The format is the following:
  // pluginArgs: {
  //   'dev.perfetto.PluginFoo': { 'key1': 'value1', 'key2': 1234 }
  //   'dev.perfetto.PluginBar': { 'key3': '...', 'key4': ... }
  // }
  pluginArgs?: {[pluginId: string]: {[key: string]: unknown}};
}

interface PostedTraceWrapped {
  perfetto: PostedTrace;
}

interface PostedScrollToRangeWrapped {
  perfetto: PostedScrollToRange;
}

interface PostedScrollToRange {
  timeStart: number;
  timeEnd: number;
  viewPercentage?: number;
}

// Returns whether incoming traces should be opened automatically or should
// instead require a user interaction.
export function isTrustedOrigin(origin: string): boolean {
  const TRUSTED_ORIGINS = [
    'https://chrometto.googleplex.com',
    'https://uma.googleplex.com',
    'https://android-build.googleplex.com',
  ];
  if (origin === window.origin) return true;
  if (origin === 'null') return false;
  if (TRUSTED_ORIGINS.includes(origin)) return true;
  if (isUserTrustedOrigin(origin)) return true;

  const hostname = new URL(origin).hostname;
  if (hostname.endsWith('.corp.google.com')) return true;
  if (hostname.endsWith('.c.googlers.com')) return true;
  if (hostname.endsWith('.proxy.googlers.com')) return true;
  if (
    hostname === 'localhost' ||
    hostname === '127.0.0.1' ||
    hostname === '[::1]'
  ) {
    return true;
  }
  return false;
}

// Returns whether the user saved this as an always-trusted origin.
function isUserTrustedOrigin(hostname: string): boolean {
  const trustedOrigins = window.localStorage.getItem(TRUSTED_ORIGINS_KEY);
  if (trustedOrigins === null) return false;
  try {
    return JSON.parse(trustedOrigins).includes(hostname);
  } catch {
    return false;
  }
}

// Saves the given hostname as a trusted origin.
// This is used for user convenience: if it fails for any reason, it's not a
// big deal.
function saveUserTrustedOrigin(hostname: string) {
  const s = window.localStorage.getItem(TRUSTED_ORIGINS_KEY);
  let origins: string[];
  try {
    origins = JSON.parse(s ?? '[]');
    if (origins.includes(hostname)) return;
    origins.push(hostname);
    window.localStorage.setItem(TRUSTED_ORIGINS_KEY, JSON.stringify(origins));
  } catch (e) {
    console.warn('unable to save trusted origins to localStorage', e);
  }
}

// Returns whether we should ignore a given message based on the value of
// the 'perfettoIgnore' field in the event data.
function shouldGracefullyIgnoreMessage(messageEvent: MessageEvent) {
  return messageEvent.data.perfettoIgnore === true;
}

// The message handler supports loading traces from an ArrayBuffer.
// There is no other requirement than sending the ArrayBuffer as the |data|
// property. However, since this will happen across different origins, it is not
// possible for the source website to inspect whether the message handler is
// ready, so the message handler always replies to a 'PING' message with 'PONG',
// which indicates it is ready to receive a trace.
export function postMessageHandler(messageEvent: MessageEvent) {
  if (shouldGracefullyIgnoreMessage(messageEvent)) {
    // This message should not be handled in this handler,
    // because it will be handled elsewhere.
    return;
  }

  if (messageEvent.origin === 'https://tagassistant.google.com') {
    // The GA debugger, does a window.open() and sends messages to the GA
    // script. Ignore them.
    return;
  }

  if (document.readyState !== 'complete') {
    console.error('Ignoring message - document not ready yet.');
    return;
  }

  const fromOpener = messageEvent.source === window.opener;
  const fromIframeHost = messageEvent.source === window.parent;
  // This adds support for the folowing flow:
  // * A (page that whats to open a trace in perfetto) opens B
  // * B (does something to get the traceBuffer)
  // * A is navigated to Perfetto UI
  // * B sends the traceBuffer to A
  // * closes itself
  const fromOpenee = (messageEvent.source as WindowProxy).opener === window;

  if (
    messageEvent.source === null ||
    !(fromOpener || fromIframeHost || fromOpenee)
  ) {
    // This can happen if an extension tries to postMessage.
    return;
  }

  if (!('data' in messageEvent)) {
    throw new Error('Incoming message has no data property');
  }

  if (messageEvent.data === 'PING') {
    // Cross-origin messaging means we can't read |messageEvent.source|, but
    // it still needs to be of the correct type to be able to invoke the
    // correct version of postMessage(...).
    const windowSource = messageEvent.source as Window;

    // Use '*' for the reply because in cases of cross-domain isolation, we
    // see the messageEvent.origin as 'null'. PONG doen't disclose any
    // interesting information, so there is no harm sending that to the wrong
    // origin in the worst case.
    windowSource.postMessage('PONG', '*');
    return;
  }

  if (messageEvent.data === 'SHOW-HELP') {
    toggleHelp();
    return;
  }

  if (messageEvent.data === 'RELOAD-CSS-CONSTANTS') {
    initCssConstants();
    return;
  }

  let postedScrollToRange: PostedScrollToRange;
  if (isPostedScrollToRange(messageEvent.data)) {
    postedScrollToRange = messageEvent.data.perfetto;
    scrollToTimeRange(postedScrollToRange);
    return;
  }

  let postedTrace: PostedTrace;
  let keepApiOpen = false;
  if (isPostedTraceWrapped(messageEvent.data)) {
    postedTrace = sanitizePostedTrace(messageEvent.data.perfetto);
    if (postedTrace.keepApiOpen) {
      keepApiOpen = true;
    }
  } else if (messageEvent.data instanceof ArrayBuffer) {
    postedTrace = {title: 'External trace', buffer: messageEvent.data};
  } else {
    console.warn(
      'Unknown postMessage() event received. If you are trying to open a ' +
        'trace via postMessage(), this is a bug in your code. If not, this ' +
        'could be due to some Chrome extension.',
    );
    console.log('origin:', messageEvent.origin, 'data:', messageEvent.data);
    return;
  }

  if (postedTrace.buffer.byteLength === 0) {
    throw new Error('Incoming message trace buffer is empty');
  }

  if (!keepApiOpen) {
    /* Removing this event listener to avoid callers posting the trace multiple
     * times. If the callers add an event listener which upon receiving 'PONG'
     * posts the trace to ui.perfetto.dev, the callers can receive multiple
     * 'PONG' messages and accidentally post the trace multiple times. This was
     * part of the cause of b/182502595.
     */
    window.removeEventListener('message', postMessageHandler);
  }

  const openTrace = async () => {
    // Maybe load the app state from the URL.
    let appState: SerializedAppState | undefined;
    if (postedTrace.appStateHash) {
      if (!isValidGcsFileName(postedTrace.appStateHash)) {
        throw new Error(
          `Invalid appStateHash: ${postedTrace.appStateHash}. ` +
            `Expected a 40-char hex hash.`,
        );
      }
      const url = `https://storage.googleapis.com/${BUCKET_NAME}/${postedTrace.appStateHash}`;
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(
          `Failed to fetch app state from ${url}: ` +
            `${response.status} ${response.statusText}`,
        );
      }
      const json = (await response.json()).appState;
      const parsedState = parseAppState(json);
      if (parsedState.ok) {
        appState = parsedState.value;
      }
    }
    AppImpl.instance.openTraceFromBuffer(postedTrace, appState);
  };

  const trustAndOpenTrace = () => {
    saveUserTrustedOrigin(messageEvent.origin);
    openTrace();
  };

  // If the origin is trusted open the trace directly.
  if (isTrustedOrigin(messageEvent.origin)) {
    openTrace();
    return;
  }

  // If not ask the user if they expect this and trust the origin.
  let originTxt = messageEvent.origin;
  let originUnknown = false;
  if (originTxt === 'null') {
    originTxt = 'An unknown origin';
    originUnknown = true;
  }
  showModal({
    title: 'Open trace?',
    content: m(
      'div',
      m('div', `${originTxt} is trying to open a trace file.`),
      m('div', 'Do you trust the origin and want to proceed?'),
    ),
    buttons: [
      {text: 'No', primary: true},
      {
        text: 'Yes',
        primary: false,
        action: () => {
          openTrace();
        },
      },
    ].concat(
      originUnknown
        ? []
        : {text: 'Always trust', primary: false, action: trustAndOpenTrace},
    ),
  });
}

function sanitizePostedTrace(postedTrace: PostedTrace): PostedTrace {
  const result: PostedTrace = {
    title: sanitizeString(postedTrace.title),
    buffer: postedTrace.buffer,
    keepApiOpen: postedTrace.keepApiOpen,
    // For external traces, we need to disable other features such as
    // downloading and sharing a trace, unless the caller allows it.
    localOnly: postedTrace.localOnly ?? true,
    appStateHash: postedTrace.appStateHash,
    pluginArgs: postedTrace.pluginArgs,
  };
  if (postedTrace.url !== undefined) {
    result.url = sanitizeString(postedTrace.url);
  }
  return result;
}

function sanitizeString(str: string): string {
  return str.replace(/[^A-Za-z0-9.\-_#:/?=&;%+$ ]/g, ' ');
}

const _maxScrollToRangeAttempts = 20;
async function scrollToTimeRange(
  postedScrollToRange: PostedScrollToRange,
  maxAttempts?: number,
) {
  const app = AppImpl.instance;
  const trace = app.trace;
  if (trace && !app.isLoadingTrace) {
    const start = Time.fromSeconds(postedScrollToRange.timeStart);
    const end = Time.fromSeconds(postedScrollToRange.timeEnd);
    const behavior =
      postedScrollToRange.viewPercentage !== undefined
        ? {viewPercentage: postedScrollToRange.viewPercentage}
        : undefined;
    trace.scrollTo({
      time: {start, end, behavior},
    });
  } else {
    if (maxAttempts === undefined) {
      maxAttempts = 0;
    }
    if (maxAttempts > _maxScrollToRangeAttempts) {
      console.warn('Could not scroll to time range. Trace viewer not ready.');
      return;
    }
    setTimeout(scrollToTimeRange, 200, postedScrollToRange, maxAttempts + 1);
  }
}

function isPostedScrollToRange(
  obj: unknown,
): obj is PostedScrollToRangeWrapped {
  const wrapped = obj as PostedScrollToRangeWrapped;
  if (wrapped.perfetto === undefined) {
    return false;
  }
  return (
    wrapped.perfetto.timeStart !== undefined ||
    wrapped.perfetto.timeEnd !== undefined
  );
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isPostedTraceWrapped(obj: any): obj is PostedTraceWrapped {
  const wrapped = obj as PostedTraceWrapped;
  if (wrapped.perfetto === undefined) {
    return false;
  }
  return (
    wrapped.perfetto.buffer !== undefined &&
    wrapped.perfetto.title !== undefined
  );
}
