/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 */

import EventEmitter from 'eventemitter3';

import {State, Store} from '../reducers/index';
import {Logger} from 'flipper-common';
import {
  getPluginBackgroundStats,
  resetPluginBackgroundStatsDelta,
} from '../utils/pluginStats';
import {
  clearTimeline,
  TrackingEvent,
  State as UsageTrackingState,
  selectionChanged,
} from '../reducers/usageTracking';
import produce from 'immer';
import {BaseDevice} from 'flipper-frontend-core';
import {deconstructClientId} from 'flipper-common';
import {sideEffect} from '../utils/sideEffect';
import {getSelectionInfo} from '../utils/info';
import type {SelectionInfo} from '../utils/info';
import {getRenderHostInstance} from 'flipper-frontend-core';

const TIME_SPENT_EVENT = 'time-spent';

type UsageInterval = {
  selectionKey: string | null;
  selection: SelectionInfo | null;
  length: number;
  focused: boolean;
};

export type UsageSummary = {
  total: {focusedTime: number; unfocusedTime: number};
  plugin: {
    [pluginKey: string]: {
      focusedTime: number;
      unfocusedTime: number;
    } & SelectionInfo;
  };
};

export const fpsEmitter = new EventEmitter();

// var is fine, let doesn't have the correct hoisting semantics
// eslint-disable-next-line no-var
var bytesReceivedEmitter: EventEmitter;

export function onBytesReceived(
  callback: (plugin: string, bytes: number) => void,
): () => void {
  if (!bytesReceivedEmitter) {
    bytesReceivedEmitter = new EventEmitter();
  }
  bytesReceivedEmitter.on('bytesReceived', callback);
  return () => {
    bytesReceivedEmitter.off('bytesReceived', callback);
  };
}

export function emitBytesReceived(plugin: string, bytes: number) {
  if (bytesReceivedEmitter) {
    bytesReceivedEmitter.emit('bytesReceived', plugin, bytes);
  }
}
export default (store: Store, logger: Logger) => {
  const renderHost = getRenderHostInstance();
  sideEffect(
    store,
    {
      name: 'pluginUsageTracking',
      throttleMs: 0,
      noTimeBudgetWarns: true,
      runSynchronously: true,
    },
    getSelectionInfo,
    (selection, store) => {
      const time = Date.now();
      store.dispatch(selectionChanged({selection, time}));
    },
  );

  let droppedFrames: number = 0;
  let largeFrameDrops: number = 0;

  const oldExitData = loadExitData();
  if (oldExitData) {
    const isReload =
      renderHost.serverConfig.environmentInfo.processId === oldExitData.pid;
    const timeSinceLastStartup =
      Date.now() - parseInt(oldExitData.lastSeen, 10);

    logger.track('usage', isReload ? 'reload' : 'restart', {
      ...oldExitData,
      pid: undefined,
      timeSinceLastStartup,
    });
    // create fresh exit data
    const {selectedDevice, selectedAppId, selectedPlugin} =
      store.getState().connections;
    persistExitData(
      {
        selectedDevice,
        selectedAppId,
        selectedPlugin,
      },
      false,
    );
  }

  function droppedFrameDetection(
    past: DOMHighResTimeStamp,
    isWindowFocused: () => boolean,
  ) {
    const now = performance.now();
    requestAnimationFrame(() => droppedFrameDetection(now, isWindowFocused));
    const delta = now - past;
    const dropped = Math.round(delta / (1000 / 60) - 1);
    fpsEmitter.emit('fps', delta > 1000 ? 0 : Math.round(1000 / (now - past)));
    if (!isWindowFocused() || dropped < 1) {
      return;
    }
    droppedFrames += dropped;
    if (dropped > 3) {
      largeFrameDrops++;
    }
  }

  if (typeof window !== 'undefined') {
    droppedFrameDetection(
      performance.now(),
      () => store.getState().application.windowIsFocused,
    );
  }

  const trackUsage = (...args: any[]) => {
    let state: State;
    try {
      state = store.getState();
    } catch (e) {
      // If trackUsage is called (indirectly) through a reducer,
      // this will utterly kill Flipper.
      // Let's prevent that and log an error instead.
      console.error(
        'trackUsage triggered indirectly as side effect of a reducer',
        e,
      );
      return;
    }
    const {selectedDevice, selectedPlugin, selectedAppId, clients} =
      state.connections;

    persistExitData(
      {selectedDevice, selectedPlugin, selectedAppId},
      args[0] === 'exit',
    );

    const currentTime = Date.now();
    const usageSummary = computeUsageSummary(state.usageTracking, currentTime);

    store.dispatch(clearTimeline(currentTime));

    logger.track('usage', TIME_SPENT_EVENT, usageSummary.total);
    for (const key of Object.keys(usageSummary.plugin)) {
      logger.track(
        'usage',
        TIME_SPENT_EVENT,
        usageSummary.plugin[key],
        usageSummary.plugin[key]?.plugin ?? 'none',
      );
    }

    Object.entries(state.connections.enabledPlugins).forEach(
      ([app, plugins]) => {
        logger.track('usage', 'enabled-plugins', {
          app,
          enabledPugins: plugins,
        });
      },
    );

    const bgStats = getPluginBackgroundStats();
    logger.track('usage', 'plugin-stats', {
      cpuTime: bgStats.cpuTime,
      bytesReceived: bgStats.bytesReceived,
    });
    for (const key of Object.keys(bgStats.byPlugin)) {
      const {
        cpuTimeTotal: _a,
        messageCountTotal: _b,
        bytesReceivedTotal: _c,
        ...dataWithoutTotal
      } = bgStats.byPlugin[key];
      if (Object.values(dataWithoutTotal).some((v) => v > 0)) {
        logger.track('usage', 'plugin-stats-plugin', dataWithoutTotal, key);
      }
    }
    resetPluginBackgroundStatsDelta();

    if (
      !state.application.windowIsFocused ||
      !selectedDevice ||
      !selectedPlugin
    ) {
      return;
    }

    let app: string | null = null;
    let sdkVersion: number | null = null;

    if (selectedAppId) {
      const client = clients.get(selectedAppId);
      if (client) {
        app = client.query.app;
        sdkVersion = client.query.sdk_version || 0;
      }
    }

    const info = {
      droppedFrames,
      largeFrameDrops,
      os: selectedDevice.os,
      device: selectedDevice.title,
      plugin: selectedPlugin,
      app,
      sdkVersion,
      isForeground: state.application.windowIsFocused,
      usedJSHeapSize: (window.performance as any).memory.usedJSHeapSize,
      cpuLoad: renderHost.getPercentCPUUsage?.() ?? 0,
    };

    // reset dropped frames counter
    droppedFrames = 0;
    largeFrameDrops = 0;

    logger.track('usage', 'ping', info);
  };

  renderHost.onIpcEvent('trackUsage', trackUsage);

  setInterval(trackUsage, 60 * 1000);
};

export function computeUsageSummary(
  state: UsageTrackingState,
  currentTime: number,
) {
  const intervals: UsageInterval[] = [];
  let intervalStart = 0;
  let isFocused = false;
  let selection: SelectionInfo | null = null;
  let selectionKey: string | null;

  function startInterval(event: TrackingEvent) {
    intervalStart = event.time;
    if (
      event.type === 'TIMELINE_START' ||
      event.type === 'WINDOW_FOCUS_CHANGE'
    ) {
      isFocused = event.isFocused;
    }
    if (event.type === 'SELECTION_CHANGED') {
      selectionKey = event.selectionKey;
      selection = event.selection;
    }
  }
  function endInterval(time: number) {
    const length = time - intervalStart;
    intervals.push({
      length,
      focused: isFocused,
      selectionKey,
      selection,
    });
  }

  for (const event of state.timeline) {
    if (
      event.type === 'TIMELINE_START' ||
      event.type === 'WINDOW_FOCUS_CHANGE' ||
      event.type === 'SELECTION_CHANGED'
    ) {
      if (event.type !== 'TIMELINE_START') {
        endInterval(event.time);
      }
      startInterval(event);
    }
  }
  endInterval(currentTime);

  return intervals.reduce<UsageSummary>(
    (acc: UsageSummary, x: UsageInterval) =>
      produce(acc, (draft) => {
        draft.total.focusedTime += x.focused ? x.length : 0;
        draft.total.unfocusedTime += x.focused ? 0 : x.length;
        const selectionKey = x.selectionKey ?? 'none';
        draft.plugin[selectionKey] = draft.plugin[selectionKey] ?? {
          focusedTime: 0,
          unfocusedTime: 0,
          ...x.selection,
        };
        draft.plugin[selectionKey].focusedTime += x.focused ? x.length : 0;
        draft.plugin[selectionKey].unfocusedTime += x.focused ? 0 : x.length;
      }),
    {
      total: {focusedTime: 0, unfocusedTime: 0},
      plugin: {},
    },
  );
}

const flipperExitDataKey = 'FlipperExitData';

interface ExitData {
  lastSeen: string;
  deviceOs: string;
  deviceType: string;
  deviceTitle: string;
  plugin: string;
  app: string;
  cleanExit: boolean;
  pid: number;
}

function loadExitData(): ExitData | undefined {
  if (!window.localStorage) {
    return undefined;
  }
  const data = window.localStorage.getItem(flipperExitDataKey);
  if (data) {
    try {
      const res = JSON.parse(data);
      if (res.cleanExit === undefined) {
        res.cleanExit = true; // avoid skewing results for historical data where this info isn't present
      }
      return res;
    } catch (e) {
      console.warn('Failed to parse flipperExitData', e);
    }
  }
  return undefined;
}

export function persistExitData(
  state: {
    selectedDevice: BaseDevice | null;
    selectedPlugin: string | null;
    selectedAppId: string | null;
  },
  cleanExit: boolean,
) {
  if (!window.localStorage) {
    return;
  }
  const exitData: ExitData = {
    lastSeen: '' + Date.now(),
    deviceOs: state.selectedDevice ? state.selectedDevice.os : '',
    deviceType: state.selectedDevice ? state.selectedDevice.deviceType : '',
    deviceTitle: state.selectedDevice ? state.selectedDevice.title : '',
    plugin: state.selectedPlugin || '',
    app: state.selectedAppId
      ? deconstructClientId(state.selectedAppId).app
      : '',
    cleanExit,
    pid: getRenderHostInstance().serverConfig.environmentInfo.processId,
  };
  window.localStorage.setItem(
    flipperExitDataKey,
    JSON.stringify(exitData, null, 2),
  );
}
