// Copyright (C) 2024 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 {
  JsonSerialize,
  parseAppState,
  serializeAppState,
} from '../core/state_serialization';
import {
  BUCKET_NAME,
  MIME_BINARY,
  MIME_JSON,
  GcsUploader,
  isValidGcsFileName,
} from '../base/gcs_uploader';
import {
  SERIALIZED_STATE_VERSION,
  type SerializedAppState,
} from '../core/state_serialization_schema';
import {z} from 'zod';
import {showModal} from '../widgets/modal';
import {AppImpl} from '../core/app_impl';
import type {TraceImpl} from '../core/trace_impl';

// Permalink serialization has two layers:
// 1. Serialization of the app state (state_serialization.ts):
//    This is a JSON object that represents the visual app state (pinned tracks,
//    visible viewport bounds, etc) BUT not the trace source.
// 2. An outer layer that contains the app state AND a link to the trace file.
//    (This file)
//
// In a nutshell:
//   AppState:  {viewport: {...}, pinnedTracks: {...}, notes: {...}}
//   Permalink: {appState: {see above}, traceUrl: 'https://gcs/trace/file'}
//
// This file deals with the outer layer, state_serialization.ts with the inner.

const PERMALINK_SCHEMA = z.object({
  traceUrl: z.string().optional(),

  // We don't want to enforce validation at this level but want to delegate it
  // to parseAppState(), for two reasons:
  // 1. parseAppState() does further semantic checks (e.g. version checking).
  // 2. We want to still load the traceUrl even if the app state is invalid.
  appState: z.any().optional(),
});

type PermalinkState = z.infer<typeof PERMALINK_SCHEMA>;

/**
 * Make the trace blob available for sharing. If we have the trace blob locally
 * it will be uploaded to GCS, but if it was originally loaded from a URL, we
 * just return that URL.
 *
 * @param trace The trace to upload.
 * @returns The trace URL if the upload was successful, or undefined if the
 *          trace was already uploaded (i.e. the source type is 'URL').
 */
export async function uploadTraceBlob(
  trace: TraceImpl,
): Promise<string | undefined> {
  // Check if we need to upload the trace file, before serializing the app
  // state.
  let alreadyUploadedUrl = '';
  const traceSource = trace.traceInfo.source;
  let dataToUpload: File | ArrayBuffer | undefined = undefined;
  let traceName = trace.traceInfo.traceTitle || 'trace';
  if (traceSource.type === 'FILE') {
    dataToUpload = traceSource.file;
    traceName = dataToUpload.name;
  } else if (traceSource.type === 'ARRAY_BUFFER') {
    dataToUpload = traceSource.buffer;
  } else if (traceSource.type === 'URL') {
    alreadyUploadedUrl = traceSource.url;
  } else {
    throw new Error(`Cannot share trace ${JSON.stringify(traceSource)}`);
  }

  // Upload the trace file, unless it's already uploaded (type == 'URL').
  // Internally TraceGcsUploader will skip the upload if an object with the
  // same hash exists already.
  if (alreadyUploadedUrl) {
    return alreadyUploadedUrl;
  } else if (dataToUpload !== undefined) {
    updateStatus(`Uploading ${traceName}`);
    const uploader = new GcsUploader(dataToUpload, {
      mimeType: MIME_BINARY,
      onProgress: () => reportUpdateProgress(uploader),
    });
    await uploader.waitForCompletion();
    return uploader.uploadedUrl;
  }

  return undefined;
}

/**
 * Serializes the UI state for a given trace object and uploads it to GCS,
 * with an optional trace URL.
 *
 * @param trace The trace object to serialize and upload.
 * @param traceUrl The URL of the trace file, if available. If undefined, only
 * the app state will be uploaded.
 * @returns The hash of the uploaded file, which can be used to create a
 * permalink.
 */
export async function createPermalink(
  trace: TraceImpl,
  traceUrl: string | undefined,
): Promise<string> {
  AppImpl.instance.analytics.logEvent('Trace Actions', 'Create permalink');

  const permalinkData: PermalinkState = {
    traceUrl,
    appState: serializeAppState(trace),
  };

  // Serialize the permalink with the app state (or recording state) and upload.
  updateStatus(`Creating permalink...`);
  const permalinkJson = JsonSerialize(permalinkData);
  const uploader = new GcsUploader(permalinkJson, {
    mimeType: MIME_JSON,
    onProgress: () => reportUpdateProgress(uploader),
  });
  await uploader.waitForCompletion();

  return uploader.uploadedFileName;
}

/**
 * Loads a permalink from Google Cloud Storage.
 * This is invoked when passing !#?s=fileName to URL.
 * @param gcsFileName the file name of the cloud storage object. This is
 * expected to be a JSON file that respects the schema defined by
 * PERMALINK_SCHEMA.
 */
export async function loadPermalink(gcsFileName: string): Promise<void> {
  if (!isValidGcsFileName(gcsFileName)) {
    throw new Error(
      `Invalid permalink id: ${gcsFileName}. Expected a 40-char hex hash.`,
    );
  }
  // Otherwise, this is a request to load the permalink.
  const url = `https://storage.googleapis.com/${BUCKET_NAME}/${gcsFileName}`;
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Could not fetch permalink.\n URL: ${url}`);
  }
  const text = await response.text();
  const permalinkJson = JSON.parse(text);
  let permalink: PermalinkState;
  let error = '';

  // Try to recover permalinks generated by older versions of the UI before
  // r.android.com/3119920 .
  const convertedLegacyPermalink = tryLoadLegacyPermalink(permalinkJson);
  if (convertedLegacyPermalink !== undefined) {
    permalink = convertedLegacyPermalink;
  } else {
    const res = PERMALINK_SCHEMA.safeParse(permalinkJson);
    if (res.success) {
      permalink = res.data;
    } else {
      error = res.error.toString();
      permalink = {};
    }
  }

  let serializedAppState: SerializedAppState | undefined = undefined;
  if (permalink.appState !== undefined) {
    // This is the most common case where the permalink contains the app state
    // (and optionally a traceUrl, below).
    const parseRes = parseAppState(permalink.appState);
    if (parseRes.ok) {
      serializedAppState = parseRes.value;
    } else {
      error = parseRes.error;
    }
  }
  if (permalink.traceUrl) {
    AppImpl.instance.openTraceFromUrl(permalink.traceUrl, serializedAppState);
  }

  if (error) {
    showModal({
      title: 'Failed to restore the serialized app state',
      content: m(
        'div',
        m(
          'p',
          'Something went wrong when restoring the app state.' +
            'This is due to some backwards-incompatible change ' +
            'when the permalink is generated and then opened using ' +
            'two different UI versions.',
        ),
        m(
          'p',
          "I'm going to try to open the trace file anyways, but " +
            'the zoom level, pinned tracks and other UI ' +
            "state wont't be recovered",
        ),
        m('p', 'Error details:'),
        m('.pf-modal-logs', error),
      ),
      buttons: [
        {
          text: 'Open only the trace file',
          primary: true,
        },
      ],
    });
  }
}

// Tries to recover a previous permalink, before the split in two layers,
// where the permalink JSON contains the app state, which contains inside it
// the trace URL.
// If we suceed, convert it to a new-style JSON object preserving some minimal
// information (really just vieport and pinned tracks).
function tryLoadLegacyPermalink(data: unknown): PermalinkState | undefined {
  const legacyData = data as {
    version?: number;
    engine?: {source?: {url?: string}};
    pinnedTracks?: string[];
    frontendLocalState?: {
      visibleState?: {start?: {value?: string}; end?: {value?: string}};
    };
  };
  if (legacyData.version === undefined) return undefined;
  const vizState = legacyData.frontendLocalState?.visibleState;
  return {
    traceUrl: legacyData.engine?.source?.url,
    appState: {
      version: SERIALIZED_STATE_VERSION,
      pinnedTracks: legacyData.pinnedTracks ?? [],
      viewport: vizState
        ? {start: vizState.start?.value, end: vizState.end?.value}
        : undefined,
    } as SerializedAppState,
  } as PermalinkState;
}

function reportUpdateProgress(uploader: GcsUploader) {
  switch (uploader.state) {
    case 'UPLOADING':
      const statusTxt = `Uploading ${uploader.getEtaString()}`;
      updateStatus(statusTxt);
      break;
    case 'ERROR':
      updateStatus(`Upload failed ${uploader.error}`);
      break;
    default:
      break;
  } // switch (state)
}

function updateStatus(msg: string): void {
  AppImpl.instance.omnibox.showStatusMessage(msg);
}
