/**
 * 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.
 *
 * @flow strict-local
 * @format
 * @oncall react_native
 */

import type ReactNativeElement from '../../src/private/webapis/dom/nodes/ReactNativeElement';
import type ReadOnlyElement from '../../src/private/webapis/dom/nodes/ReadOnlyElement';
import type {
  AppContainerRootViewRef,
  DebuggingOverlayRef,
} from '../ReactNative/AppContainer-dev';
import type {NativeMethods} from '../Renderer/shims/ReactNativeTypes';
import type {
  InstanceFromReactDevTools,
  ReactDevToolsAgent,
  ReactDevToolsAgentEvents,
  ReactDevToolsGlobalHook,
} from '../Types/ReactDevToolsTypes';
import type {
  ElementRectangle,
  TraceUpdate,
} from './DebuggingOverlayNativeComponent';

import {
  findNodeHandle,
  isChildPublicInstance,
} from '../ReactNative/RendererProxy';
import processColor from '../StyleSheet/processColor';

// TODO(T171193075): __REACT_DEVTOOLS_GLOBAL_HOOK__ is always injected in dev-bundles,
// but it is not mocked in some Jest tests. We should update Jest tests setup, so it would be the same as expected testing environment.
const reactDevToolsHook: ?ReactDevToolsGlobalHook =
  window.__REACT_DEVTOOLS_GLOBAL_HOOK__;

export type DebuggingOverlayRegistrySubscriberProtocol = {
  rootViewRef: AppContainerRootViewRef,
  debuggingOverlayRef: DebuggingOverlayRef,
};

type ModernNodeUpdate = {
  id: number,
  instance: ReactNativeElement,
  color: string,
};

type LegacyNodeUpdate = {
  id: number,
  instance: NativeMethods,
  color: string,
};

class DebuggingOverlayRegistry {
  #registry: Set<DebuggingOverlayRegistrySubscriberProtocol> = new Set();
  #reactDevToolsAgent: ReactDevToolsAgent | null = null;

  constructor() {
    if (reactDevToolsHook?.reactDevtoolsAgent != null) {
      this.#onReactDevToolsAgentAttached(reactDevToolsHook.reactDevtoolsAgent);
    }

    // There could be cases when frontend is disconnected and then connected again for the same React Native runtime.
    reactDevToolsHook?.on?.(
      'react-devtools',
      this.#onReactDevToolsAgentAttached,
    );
  }

  subscribe(subscriber: DebuggingOverlayRegistrySubscriberProtocol) {
    this.#registry.add(subscriber);
  }

  unsubscribe(subscriber: DebuggingOverlayRegistrySubscriberProtocol) {
    const wasPresent = this.#registry.delete(subscriber);
    if (!wasPresent) {
      console.error(
        '[DebuggingOverlayRegistry] Unexpected argument for unsubscription, which was not previously subscribed:',
        subscriber,
      );
    }
  }

  #onReactDevToolsAgentAttached = (agent: ReactDevToolsAgent): void => {
    this.#reactDevToolsAgent = agent;

    agent.addListener('drawTraceUpdates', this.#onDrawTraceUpdates);
    agent.addListener('showNativeHighlight', this.#onHighlightElements);
    agent.addListener('hideNativeHighlight', this.#onClearElementsHighlights);
  };

  #getPublicInstanceFromInstance = (
    instanceHandle: InstanceFromReactDevTools,
  ): NativeMethods | null => {
    // `canonical.publicInstance` => Fabric
    if (instanceHandle.canonical?.publicInstance != null) {
      return instanceHandle.canonical?.publicInstance;
    }

    // `canonical` => Legacy Fabric
    if (instanceHandle.canonical != null) {
      // $FlowFixMe[incompatible-return]
      return instanceHandle.canonical;
    }

    // `instanceHandle` => Legacy renderer
    if (instanceHandle.measure != null) {
      // $FlowFixMe[incompatible-return]
      return instanceHandle;
    }

    return null;
  };

  #findLowestParentFromRegistryForInstance(
    instance: ReactNativeElement,
  ): ?DebuggingOverlayRegistrySubscriberProtocol {
    let iterator: ?ReadOnlyElement = instance;
    while (iterator != null) {
      for (const subscriber of this.#registry) {
        if (subscriber.rootViewRef.current === iterator) {
          return subscriber;
        }
      }

      iterator = iterator.parentElement;
    }

    return null;
  }

  #findLowestParentFromRegistryForInstanceLegacy(
    instance: NativeMethods,
  ): ?DebuggingOverlayRegistrySubscriberProtocol {
    const candidates: Array<DebuggingOverlayRegistrySubscriberProtocol> = [];

    for (const subscriber of this.#registry) {
      if (
        subscriber.rootViewRef.current != null &&
        // $FlowFixMe[incompatible-call] There is a lot of stuff to untangle to make types for refs work.
        isChildPublicInstance(subscriber.rootViewRef.current, instance)
      ) {
        candidates.push(subscriber);
      }
    }

    if (candidates.length === 0) {
      // In some cases, like with LogBox in custom integrations, the whole subtree for specific React root might not have an AppContainer.
      return null;
    }

    if (candidates.length === 1) {
      return candidates[0];
    }

    // If there are multiple candidates, we need to find the lowest.
    // Imagine the case when there is a modal on the screen, both of them will have their own AppContainers,
    // but modal's AppContainer is a child of screen's AppContainer.
    const candidatesWithNoChildren: Array<DebuggingOverlayRegistrySubscriberProtocol> =
      [];
    for (const potentialParent of candidates) {
      let shouldSkipThisParent = false;

      if (potentialParent.rootViewRef.current == null) {
        continue;
      }

      for (const potentialChild of candidates) {
        if (potentialChild === potentialParent) {
          continue;
        }

        if (potentialChild.rootViewRef.current == null) {
          continue;
        }

        if (
          isChildPublicInstance(
            // $FlowFixMe[incompatible-call] There is a lot of stuff to untangle to make types for refs work.
            potentialParent.rootViewRef.current,
            // $FlowFixMe[incompatible-call] There is a lot of stuff to untangle to make types for refs work.
            potentialChild.rootViewRef.current,
          )
        ) {
          shouldSkipThisParent = true;
          break;
        }
      }

      if (!shouldSkipThisParent) {
        candidatesWithNoChildren.push(potentialParent);
      }
    }

    if (candidatesWithNoChildren.length === 0) {
      console.error(
        '[DebuggingOverlayRegistry] Unexpected circular relationship between AppContainers',
      );
      return null;
    } else if (candidatesWithNoChildren.length > 1) {
      console.error(
        '[DebuggingOverlayRegistry] Unexpected multiple options for lowest parent AppContainer',
      );
      return null;
    }

    return candidatesWithNoChildren[0];
  }

  #onDrawTraceUpdates: (
    ...ReactDevToolsAgentEvents['drawTraceUpdates']
  ) => void = traceUpdates => {
    const modernNodesUpdates: Array<ModernNodeUpdate> = [];
    const legacyNodesUpdates: Array<LegacyNodeUpdate> = [];

    for (const {node, color} of traceUpdates) {
      const publicInstance = this.#getPublicInstanceFromInstance(node);
      if (publicInstance == null) {
        return;
      }

      const instanceReactTag = findNodeHandle(node);
      if (instanceReactTag == null) {
        return;
      }

      // Lazy import to avoid dependency cycle.
      const ReactNativeElementClass =
        require('../../src/private/webapis/dom/nodes/ReactNativeElement').default;
      if (publicInstance instanceof ReactNativeElementClass) {
        modernNodesUpdates.push({
          id: instanceReactTag,
          instance: publicInstance,
          color,
        });
      } else {
        legacyNodesUpdates.push({
          id: instanceReactTag,
          instance: publicInstance,
          color,
        });
      }
    }

    if (modernNodesUpdates.length > 0) {
      this.#drawTraceUpdatesModern(modernNodesUpdates);
    }

    if (legacyNodesUpdates.length > 0) {
      this.#drawTraceUpdatesLegacy(legacyNodesUpdates);
    }
  };

  #drawTraceUpdatesModern(updates: Array<ModernNodeUpdate>): void {
    const parentToTraceUpdatesMap = new Map<
      DebuggingOverlayRegistrySubscriberProtocol,
      Array<TraceUpdate>,
    >();
    for (const {id, instance, color} of updates) {
      const parent = this.#findLowestParentFromRegistryForInstance(instance);
      if (parent == null) {
        continue;
      }

      let traceUpdatesForParent = parentToTraceUpdatesMap.get(parent);
      if (traceUpdatesForParent == null) {
        traceUpdatesForParent = [];
        parentToTraceUpdatesMap.set(parent, traceUpdatesForParent);
      }

      const {x, y, width, height} = instance.getBoundingClientRect();

      const rootViewInstance = parent.rootViewRef.current;
      if (rootViewInstance == null) {
        continue;
      }

      const {x: parentX, y: parentY} =
        // $FlowFixMe[prop-missing] React Native View is not a descendant of ReactNativeElement yet. We should be able to remove it once Paper is no longer supported.
        rootViewInstance.getBoundingClientRect();

      // DebuggingOverlay will scale to the same size as a Root view. Substract Root view position from the element position
      // to calculate the element's position relatively to its parent DebuggingOverlay.
      // We can't call `getBoundingClientRect` on the debuggingOverlayRef, because its a ref for the native component, which doesn't have it, hopefully yet.
      traceUpdatesForParent.push({
        id,
        rectangle: {x: x - parentX, y: y - parentY, width, height},
        color: processColor(color),
      });
    }

    for (const [parent, traceUpdates] of parentToTraceUpdatesMap.entries()) {
      const {debuggingOverlayRef} = parent;
      debuggingOverlayRef.current?.highlightTraceUpdates(traceUpdates);
    }
  }

  // TODO: remove once DOM Node APIs are opt-in by default and Paper is no longer supported.
  #drawTraceUpdatesLegacy(updates: Array<LegacyNodeUpdate>): void {
    const parentToTraceUpdatesPromisesMap = new Map<
      DebuggingOverlayRegistrySubscriberProtocol,
      Array<Promise<TraceUpdate>>,
    >();

    for (const {id, instance, color} of updates) {
      const parent =
        this.#findLowestParentFromRegistryForInstanceLegacy(instance);

      if (parent == null) {
        continue;
      }

      let traceUpdatesPromisesForParent =
        parentToTraceUpdatesPromisesMap.get(parent);
      if (traceUpdatesPromisesForParent == null) {
        traceUpdatesPromisesForParent = [];
        parentToTraceUpdatesPromisesMap.set(
          parent,
          traceUpdatesPromisesForParent,
        );
      }

      const frameToDrawPromise = new Promise<TraceUpdate>((resolve, reject) => {
        instance.measure((x, y, width, height, left, top) => {
          // measure can execute callback without any values provided to signal error.
          if (left == null || top == null || width == null || height == null) {
            reject('Unexpectedly failed to call measure on an instance.');
          }

          resolve({
            id,
            rectangle: {x: left, y: top, width, height},
            color: processColor(color),
          });
        });
      });

      traceUpdatesPromisesForParent.push(frameToDrawPromise);
    }

    for (const [
      parent,
      traceUpdatesPromises,
    ] of parentToTraceUpdatesPromisesMap.entries()) {
      Promise.all(traceUpdatesPromises)
        .then(resolvedTraceUpdates =>
          parent.debuggingOverlayRef.current?.highlightTraceUpdates(
            resolvedTraceUpdates,
          ),
        )
        .catch(() => {
          // noop. For legacy architecture (Paper) this can happen for root views or LogBox button.
          // LogBox case: it has a separate React root, so `measure` fails.
          // Calling `console.error` here would trigger rendering a new LogBox button, for which we will call measure again, this is a cycle.
          // Don't spam the UI with errors for such cases.
        });
    }
  }

  #onHighlightElements: (
    ...ReactDevToolsAgentEvents['showNativeHighlight']
  ) => void = nodes => {
    // First clear highlights for every container
    for (const subscriber of this.#registry) {
      subscriber.debuggingOverlayRef.current?.clearElementsHighlight();
    }

    // Lazy import to avoid dependency cycle.
    const ReactNativeElementClass =
      require('../../src/private/webapis/dom/nodes/ReactNativeElement').default;

    const reactNativeElements: Array<ReactNativeElement> = [];
    const legacyPublicInstances: Array<NativeMethods> = [];

    for (const node of nodes) {
      const publicInstance = this.#getPublicInstanceFromInstance(node);
      if (publicInstance == null) {
        continue;
      }

      if (publicInstance instanceof ReactNativeElementClass) {
        reactNativeElements.push(publicInstance);
      } else {
        legacyPublicInstances.push(publicInstance);
      }
    }

    if (reactNativeElements.length > 0) {
      this.#onHighlightElementsModern(reactNativeElements);
    }

    if (legacyPublicInstances.length > 0) {
      this.#onHighlightElementsLegacy(legacyPublicInstances);
    }
  };

  #onHighlightElementsModern(elements: Array<ReactNativeElement>): void {
    const parentToElementsMap = new Map<
      DebuggingOverlayRegistrySubscriberProtocol,
      Array<ReactNativeElement>,
    >();

    for (const element of elements) {
      const parent = this.#findLowestParentFromRegistryForInstance(element);
      if (parent == null) {
        continue;
      }

      let childElementOfAParent = parentToElementsMap.get(parent);
      if (childElementOfAParent == null) {
        childElementOfAParent = [];
        parentToElementsMap.set(parent, childElementOfAParent);
      }

      childElementOfAParent.push(element);
    }

    for (const [parent, elementsToHighlight] of parentToElementsMap.entries()) {
      const rootViewInstance = parent.rootViewRef.current;
      if (rootViewInstance == null) {
        return;
      }

      const {x: parentX, y: parentY} =
        // $FlowFixMe[prop-missing] React Native View is not a descendant of ReactNativeElement yet. We should be able to remove it once Paper is no longer supported.
        rootViewInstance.getBoundingClientRect();

      // DebuggingOverlay will scale to the same size as a Root view. Substract Root view position from the element position
      // to calculate the element's position relatively to its parent DebuggingOverlay.
      // We can't call `getBoundingClientRect` on the debuggingOverlayRef, because its a ref for the native component, which doesn't have it, hopefully yet.
      const elementsRectangles = elementsToHighlight.map(element => {
        const {x, y, width, height} = element.getBoundingClientRect();
        return {x: x - parentX, y: y - parentY, width, height};
      });

      parent.debuggingOverlayRef.current?.highlightElements(elementsRectangles);
    }
  }

  // TODO: remove once DOM Node APIs are opt-in by default and Paper is no longer supported.
  #onHighlightElementsLegacy(elements: Array<NativeMethods>): void {
    const parentToElementsMap = new Map<
      DebuggingOverlayRegistrySubscriberProtocol,
      Array<NativeMethods>,
    >();

    for (const element of elements) {
      const parent =
        this.#findLowestParentFromRegistryForInstanceLegacy(element);
      if (parent == null) {
        continue;
      }

      let childElementOfAParent = parentToElementsMap.get(parent);
      if (childElementOfAParent == null) {
        childElementOfAParent = [];
        parentToElementsMap.set(parent, childElementOfAParent);
      }

      childElementOfAParent.push(element);
    }

    for (const [parent, elementsToHighlight] of parentToElementsMap.entries()) {
      const promises = elementsToHighlight.map(
        element =>
          new Promise<ElementRectangle>((resolve, reject) => {
            element.measure((x, y, width, height, left, top) => {
              // measure can execute callback without any values provided to signal error.
              if (
                left == null ||
                top == null ||
                width == null ||
                height == null
              ) {
                reject('Unexpectedly failed to call measure on an instance.');
              }

              resolve({x: left, y: top, width, height});
            });
          }),
      );

      Promise.all(promises)
        .then(resolvedElementsRectangles =>
          parent.debuggingOverlayRef.current?.highlightElements(
            resolvedElementsRectangles,
          ),
        )
        .catch(() => {
          // noop. For legacy architecture (Paper) this can happen for root views or LogBox button.
          // LogBox case: it has a separate React root, so `measure` fails.
          // Calling `console.error` here would trigger rendering a new LogBox button, for which we will call measure again, this is a cycle.
          // Don't spam the UI with errors for such cases.
        });
    }
  }

  #onClearElementsHighlights: (
    ...ReactDevToolsAgentEvents['hideNativeHighlight']
  ) => void = () => {
    for (const subscriber of this.#registry) {
      subscriber.debuggingOverlayRef.current?.clearElementsHighlight();
    }
  };
}

const debuggingOverlayRegistryInstance: DebuggingOverlayRegistry =
  new DebuggingOverlayRegistry();
export default debuggingOverlayRegistryInstance;
