import {
  _renderers,
  ActivityComponentTag,
  ClassComponentTag,
  Fiber,
  ForwardRefTag,
  FunctionComponentTag,
  getRDTHook,
  HostComponentTag,
  HostHoistableTag,
  HostSingletonTag,
  LazyComponentTag,
  SimpleMemoComponentTag,
  SuspenseComponentTag,
  SuspenseListComponentTag,
  ViewTransitionComponentTag,
  getDisplayName,
  traverseFiber,
} from "../core.js";
import { SERVER_FRAME_MARKER, SERVER_ENV_PATTERN, ABOUT_REACT_PREFIX } from "./constants.js";

import { parseStack, StackFrame } from "./parse-stack.js";
import { symbolicateStack } from "./symbolication.js";

export const hasDebugStack = (
  fiber: Fiber,
): fiber is Fiber & {
  _debugStack: NonNullable<Fiber["_debugStack"]>;
} => {
  return fiber._debugStack instanceof Error && typeof fiber._debugStack?.stack === "string";
};

const getCurrentDispatcher = (): null | React.RefObject<unknown> => {
  const rdtHook = getRDTHook();
  for (const renderer of [...Array.from(_renderers), ...Array.from(rdtHook.renderers.values())]) {
    const currentDispatcherRef = renderer.currentDispatcherRef;
    if (currentDispatcherRef && typeof currentDispatcherRef === "object") {
      return "H" in currentDispatcherRef ? currentDispatcherRef.H : currentDispatcherRef.current;
    }
  }
  return null;
};

const setCurrentDispatcher = (value: null | React.RefObject<unknown>): void => {
  for (const renderer of _renderers) {
    const currentDispatcherRef = renderer.currentDispatcherRef;
    if (currentDispatcherRef && typeof currentDispatcherRef === "object") {
      if ("H" in currentDispatcherRef) {
        currentDispatcherRef.H = value;
      } else {
        currentDispatcherRef.current = value;
      }
    }
  }
};

const describeBuiltInComponentFrame = (name: string): string => {
  return `\n    in ${name}`;
};

export const describeDebugInfoFrame = (name: string, env?: string): string => {
  let frameDescription = describeBuiltInComponentFrame(name);
  if (env) {
    frameDescription += ` (at ${env})`;
  }
  return frameDescription;
};

let reEntry = false;

// https://github.com/facebook/react/blob/f739642745577a8e4dcb9753836ac3589b9c590a/packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js#L22
const describeNativeComponentFrame = (
  component: React.ComponentType<unknown>,
  construct: boolean,
): string => {
  if (!component || reEntry) {
    return "";
  }

  const previousPrepareStackTrace = Error.prepareStackTrace;
  // HACK: V8 API allows undefined but bun-types declares it as non-optional
  (Error as { prepareStackTrace?: typeof Error.prepareStackTrace }).prepareStackTrace = undefined;
  reEntry = true;

  const previousDispatcher = getCurrentDispatcher();
  setCurrentDispatcher(null);
  const previousConsoleError = console.error;
  const previousConsoleWarn = console.warn;
  console.error = () => {};
  console.warn = () => {};
  try {
    /**
     * Finding a common stack frame between sample and control errors can be
     * tricky given the different types and levels of stack trace truncation from
     * different JS VMs. So instead we'll attempt to control what that common
     * frame should be through this object method:
     * Having both the sample and control errors be in the function under the
     * `DescribeNativeComponentFrameRoot` property, + setting the `name` and
     * `displayName` properties of the function ensures that a stack
     * frame exists that has the method name `DescribeNativeComponentFrameRoot` in
     * it for both control and sample stacks.
     */
    const RunInRootFrame = {
      DetermineComponentFrameRoot() {
        let control: unknown;
        try {
          // This should throw.
          if (construct) {
            // Something should be setting the props in the constructor.
            const ThrowingConstructor = function () {
              throw Error();
            };
            Object.defineProperty(ThrowingConstructor.prototype, "props", {
              set: function () {
                // We use a throwing setter instead of frozen or non-writable props
                // because that won't throw in a non-strict mode function.
                throw Error();
              },
            });
            if (typeof Reflect === "object" && Reflect.construct) {
              // We construct a different control for this case to include any extra
              // frames added by the construct call.
              try {
                Reflect.construct(ThrowingConstructor, []);
              } catch (caughtError) {
                control = caughtError;
              }
              Reflect.construct(component, [], ThrowingConstructor);
            } else {
              try {
                // @ts-expect-error -- ThrowingConstructor is a constructor function
                ThrowingConstructor.call();
              } catch (caughtError) {
                control = caughtError;
              }
              // @ts-expect-error -- ThrowingConstructor is a constructor function
              component.call(ThrowingConstructor.prototype);
            }
          } else {
            try {
              throw Error();
            } catch (caughtError) {
              control = caughtError;
            }
            // TODO(luna): This will currently only throw if the function component
            // tries to access React/ReactDOM/props. We should probably make this throw
            // in simple components too
            const maybePromise = (component as () => Promise<unknown>)();

            // If the function component returns a promise, it's likely an async
            // component, which we don't yet support. Attach a noop catch handler to
            // silence the error.
            // TODO: Implement component stacks for async client components?
            // eslint-disable-next-line @typescript-eslint/no-misused-promises -- we literally check if this is a promise here
            if (maybePromise && typeof maybePromise.catch === "function") {
              maybePromise.catch(() => {});
            }
          }
        } catch (sample: unknown) {
          // This is inlined manually because closure doesn't do it for us.
          if (
            sample instanceof Error &&
            control instanceof Error &&
            typeof sample.stack === "string"
          ) {
            return [sample.stack, control.stack];
          }
        }
        return [null, null];
      },
    };

    // @ts-expect-error --- displayName is not a property of the function
    RunInRootFrame.DetermineComponentFrameRoot.displayName = "DetermineComponentFrameRoot";
    const namePropDescriptor = Object.getOwnPropertyDescriptor(
      // eslint-disable-next-line @typescript-eslint/unbound-method
      RunInRootFrame.DetermineComponentFrameRoot,
      "name",
    );
    // Before ES6, the `name` property was not configurable.
    if (namePropDescriptor?.configurable) {
      // V8 utilizes a function's `name` property when generating a stack trace.
      Object.defineProperty(
        // eslint-disable-next-line @typescript-eslint/unbound-method
        RunInRootFrame.DetermineComponentFrameRoot,
        // Configurable properties can be updated even if its writable descriptor
        // is set to `false`.
        "name",
        { value: "DetermineComponentFrameRoot" },
      );
    }

    const [sampleStack, controlStack] = RunInRootFrame.DetermineComponentFrameRoot();
    if (sampleStack && controlStack) {
      // This extracts the first frame from the sample that isn't also in the control.
      // Skipping one frame that we assume is the frame that calls the two.
      const sampleLines = sampleStack.split("\n");
      const controlLines = controlStack.split("\n");
      let sampleIndex = 0;
      let controlIndex = 0;
      while (
        sampleIndex < sampleLines.length &&
        !sampleLines[sampleIndex].includes("DetermineComponentFrameRoot")
      ) {
        sampleIndex++;
      }
      while (
        controlIndex < controlLines.length &&
        !controlLines[controlIndex].includes("DetermineComponentFrameRoot")
      ) {
        controlIndex++;
      }
      // We couldn't find our intentionally injected common root frame, attempt
      // to find another common root frame by search from the bottom of the
      // control stack...
      if (sampleIndex === sampleLines.length || controlIndex === controlLines.length) {
        sampleIndex = sampleLines.length - 1;
        controlIndex = controlLines.length - 1;
        while (
          sampleIndex >= 1 &&
          controlIndex >= 0 &&
          sampleLines[sampleIndex] !== controlLines[controlIndex]
        ) {
          // We expect at least one stack frame to be shared.
          // Typically this will be the root most one. However, stack frames may be
          // cut off due to maximum stack limits. In this case, one maybe cut off
          // earlier than the other. We assume that the sample is longer or the same
          // and there for cut off earlier. So we should find the root most frame in
          // the sample somewhere in the control.
          controlIndex--;
        }
      }
      for (; sampleIndex >= 1 && controlIndex >= 0; sampleIndex--, controlIndex--) {
        // Next we find the first one that isn't the same which should be the
        // frame that called our sample function and the control.
        if (sampleLines[sampleIndex] !== controlLines[controlIndex]) {
          // In V8, the first line is describing the message but other VMs don't.
          // If we're about to return the first line, and the control is also on the same
          // line, that's a pretty good indicator that our sample threw at same line as
          // the control. I.e. before we entered the sample frame. So we ignore this result.
          // This can happen if you passed a class to function component, or non-function.
          if (sampleIndex !== 1 || controlIndex !== 1) {
            do {
              sampleIndex--;
              controlIndex--;
              // We may still have similar intermediate frames from the construct call.
              // The next one that isn't the same should be our match though.
              if (controlIndex < 0 || sampleLines[sampleIndex] !== controlLines[controlIndex]) {
                // V8 adds a "new" prefix for native classes. Let's remove it to make it prettier.
                let stackFrame = `\n${sampleLines[sampleIndex].replace(" at new ", " at ")}`;

                const displayName = getDisplayName(component);
                // If our component frame is labeled "<anonymous>"
                // but we have a user-provided "displayName"
                // splice it in to make the stack more readable.
                if (displayName && stackFrame.includes("<anonymous>")) {
                  stackFrame = stackFrame.replace("<anonymous>", displayName);
                }
                // Return the line we found.
                return stackFrame;
              }
            } while (sampleIndex >= 1 && controlIndex >= 0);
          }
          break;
        }
      }
    }
  } finally {
    reEntry = false;

    Error.prepareStackTrace = previousPrepareStackTrace;

    setCurrentDispatcher(previousDispatcher);
    console.error = previousConsoleError;
    console.warn = previousConsoleWarn;
  }

  const componentName = component ? getDisplayName(component) : "";
  const syntheticFrame = componentName ? describeBuiltInComponentFrame(componentName) : "";
  return syntheticFrame;
};

// https://github.com/facebook/react/blob/ac3e705a18696168acfcaed39dce0cfaa6be8836/packages/react-reconciler/src/ReactFiberComponentStack.js#L180
export const describeFiber = (fiber: Fiber, childFiber: Fiber | null): string => {
  const tag = fiber.tag as number;
  let stackFrame = "";
  switch (tag) {
    case ActivityComponentTag:
      stackFrame = describeBuiltInComponentFrame("Activity");
      break;
    case ClassComponentTag:
      stackFrame = describeNativeComponentFrame(fiber.type, true);
      break;
    case ForwardRefTag:
      stackFrame = describeNativeComponentFrame(
        (fiber.type as { render: React.ComponentType<unknown> }).render,
        false,
      );
      break;
    case FunctionComponentTag:
    case SimpleMemoComponentTag:
      stackFrame = describeNativeComponentFrame(fiber.type, false);
      break;
    case HostComponentTag:
    case HostHoistableTag:
    case HostSingletonTag:
      stackFrame = describeBuiltInComponentFrame(fiber.type as string);
      break;
    case LazyComponentTag:
      // TODO: When we support Thenables as component types we should rename this.
      stackFrame = describeBuiltInComponentFrame("Lazy");
      break;
    case SuspenseComponentTag:
      if (fiber.child !== childFiber && childFiber !== null) {
        // If we came from the second Fiber then we're in the Suspense Fallback.
        stackFrame = describeBuiltInComponentFrame("Suspense Fallback");
      } else {
        stackFrame = describeBuiltInComponentFrame("Suspense");
      }
      break;
    case SuspenseListComponentTag:
      stackFrame = describeBuiltInComponentFrame("SuspenseList");
      break;
    case ViewTransitionComponentTag:
      // Note: enableViewTransition feature flag is not available in this codebase,
      // so we'll always include ViewTransition
      stackFrame = describeBuiltInComponentFrame("ViewTransition");
      break;
    default:
      return "";
  }

  return stackFrame;
};

/**
 * react 19 introduces the _debugStack property, which we can use to grab the stack.
 * however, for versions that don't have this property, we need to construct
 * a "fake" version of the owner stack
 */
export const getFallbackOwnerStack = (thisFiber: Fiber): string => {
  try {
    let componentStack = "";
    let currentFiber: Fiber | null = thisFiber;
    let previousFiber: Fiber | null = null;
    do {
      componentStack += describeFiber(currentFiber, previousFiber);

      // Add any Server Component stack frames in reverse order (dev only).
      // Since we don't have __DEV__ in this codebase, we'll check for _debugInfo
      const debugInfo = currentFiber._debugInfo;
      if (debugInfo && Array.isArray(debugInfo)) {
        for (let i = debugInfo.length - 1; i >= 0; i--) {
          const debugEntry = debugInfo[i];
          if (typeof debugEntry.name === "string") {
            componentStack += describeDebugInfoFrame(debugEntry.name, debugEntry.env);
          }
        }
      }

      previousFiber = currentFiber;
      currentFiber = currentFiber.return;
    } while (currentFiber);
    return componentStack;
  } catch (error) {
    if (error instanceof Error) {
      return `\nError generating stack: ${error.message}\n${error.stack}`;
    }
    return "";
  }
};

/**
 * takes Error.stack and formats it to only the React owner stack
 *
 * before:
 * ```
 * Error: react-stack-top-frame
 * at fakeJSXCallSite (http://localhost:3000/_next/static/chunks/<chunk-name>._.js:17665:16)
 * at TodoItem (rsc://React/Server/file:///path/to/project/.next/server/chunks/ssr/<chunk-name>._.js)
 * at react-stack-bottom-frame (http://localhost:3000/_next/static/chunks/<chunk-name>._.js:17984:89)
 * ```
 *
 * after:
 * ```
 * at TodoItem (rsc://React/Server/file:///path/to/project/.next/server/chunks/ssr/<chunk-name>._.js)
 * ```
 *
 * @see https://github.com/facebook/react/blob/main/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js#L12
 */
export const formatOwnerStack = (stack: string): string => {
  const prevPrepareStackTrace = Error.prepareStackTrace;
  // HACK: V8 API allows undefined but bun-types declares it as non-optional
  (Error as { prepareStackTrace?: typeof Error.prepareStackTrace }).prepareStackTrace = undefined;
  let formattedStack = stack;
  if (!formattedStack) {
    return "";
  }
  Error.prepareStackTrace = prevPrepareStackTrace;

  if (formattedStack.startsWith("Error: react-stack-top-frame\n")) {
    // V8's default formatting prefixes with the error message which we
    // don't want/need
    formattedStack = formattedStack.slice(29);
  }
  let idx = formattedStack.indexOf("\n");
  if (idx !== -1) {
    // pop the JSX frame
    formattedStack = formattedStack.slice(idx + 1);
  }
  idx = Math.max(
    formattedStack.indexOf("react_stack_bottom_frame"),
    formattedStack.indexOf("react-stack-bottom-frame"),
  );
  if (idx !== -1) {
    idx = formattedStack.lastIndexOf("\n", idx);
  }
  if (idx !== -1) {
    // cut off everything after the bottom frame since it'll be internals.
    formattedStack = formattedStack.slice(0, idx);
  } else {
    // we didn't find any internal callsite out to user space.
    // This means that this was called outside an owner or the owner is fully internal.
    // to keep things light we exclude the entire trace in this case.
    return "";
  }
  return formattedStack;
};

interface OwnerStackEntry {
  componentName: string;
  stackFrames: StackFrame[];
}

const isReactServerComponentFrame = (stackFrame: StackFrame): boolean =>
  Boolean(
    stackFrame.functionName &&
      stackFrame.fileName &&
      (stackFrame.fileName.startsWith("rsc://") ||
        stackFrame.fileName.startsWith(ABOUT_REACT_PREFIX)),
  );

const areStackFramesEqual = (firstFrame: StackFrame, secondFrame: StackFrame): boolean =>
  firstFrame.fileName === secondFrame.fileName &&
  firstFrame.lineNumber === secondFrame.lineNumber &&
  firstFrame.columnNumber === secondFrame.columnNumber;

const buildFunctionNameToRscFramesMap = (
  ownerStackEntries: OwnerStackEntry[],
): Map<string, StackFrame[]> => {
  const functionNameToRscFrames = new Map<string, StackFrame[]>();

  for (const ownerEntry of ownerStackEntries) {
    for (const stackFrame of ownerEntry.stackFrames) {
      if (!isReactServerComponentFrame(stackFrame)) continue;

      const functionName = stackFrame.functionName!;
      const framesForFunction = functionNameToRscFrames.get(functionName) ?? [];
      const isDuplicateFrame = framesForFunction.some((existingFrame) =>
        areStackFramesEqual(existingFrame, stackFrame),
      );

      if (!isDuplicateFrame) {
        framesForFunction.push(stackFrame);
        functionNameToRscFrames.set(functionName, framesForFunction);
      }
    }
  }

  return functionNameToRscFrames;
};

const getEnrichedServerStackFrame = (
  serverFrame: StackFrame,
  functionNameToRscFrames: Map<string, StackFrame[]>,
  functionNameToUsageIndex: Map<string, number>,
): StackFrame => {
  if (!serverFrame.functionName) {
    return { ...serverFrame, isServer: true };
  }

  const availableRscFrames = functionNameToRscFrames.get(serverFrame.functionName);
  if (!availableRscFrames || availableRscFrames.length === 0) {
    return { ...serverFrame, isServer: true };
  }

  const currentUsageIndex = functionNameToUsageIndex.get(serverFrame.functionName) ?? 0;
  const resolvedRscFrame = availableRscFrames[currentUsageIndex % availableRscFrames.length];
  functionNameToUsageIndex.set(serverFrame.functionName, currentUsageIndex + 1);

  return {
    ...serverFrame,
    isServer: true,
    fileName: resolvedRscFrame.fileName,
    lineNumber: resolvedRscFrame.lineNumber,
    columnNumber: resolvedRscFrame.columnNumber,
    source: serverFrame.source?.replace(
      SERVER_FRAME_MARKER,
      `(${resolvedRscFrame.fileName}:${resolvedRscFrame.lineNumber}:${resolvedRscFrame.columnNumber})`,
    ),
  };
};

const getOwnerStackEntries = (rootFiber: Fiber): OwnerStackEntry[] => {
  const ownerStackEntries: OwnerStackEntry[] = [];

  traverseFiber(
    rootFiber,
    (currentFiber) => {
      if (!hasDebugStack(currentFiber)) return;

      const componentName =
        typeof currentFiber.type !== "string"
          ? getDisplayName(currentFiber.type) || "<anonymous>"
          : currentFiber.type;

      ownerStackEntries.push({
        componentName,
        stackFrames: parseStack(formatOwnerStack(currentFiber._debugStack?.stack)),
      });
    },
    true,
  );

  return ownerStackEntries;
};

export const getOwnerStack = async (
  fiber: Fiber,
  shouldCache = true,
  fetchFunction?: (url: string) => Promise<Response>,
): Promise<StackFrame[]> => {
  const ownerStackEntries = getOwnerStackEntries(fiber);
  const fallbackStackFrames = parseStack(getFallbackOwnerStack(fiber));
  const functionNameToRscFrames = buildFunctionNameToRscFramesMap(ownerStackEntries);
  const functionNameToUsageIndex = new Map<string, number>();

  const enrichedStackFrames = fallbackStackFrames.map((stackFrame): StackFrame => {
    const isServerFrame =
      (stackFrame.source?.includes(SERVER_FRAME_MARKER) ?? false) ||
      (stackFrame.source != null && SERVER_ENV_PATTERN.test(stackFrame.source));

    if (isServerFrame) {
      return getEnrichedServerStackFrame(
        stackFrame,
        functionNameToRscFrames,
        functionNameToUsageIndex,
      );
    }

    return stackFrame;
  });

  const deduplicatedStackFrames = enrichedStackFrames.filter((stackFrame, index, frames) => {
    if (index === 0) return true;
    const previousFrame = frames[index - 1];
    return stackFrame.functionName !== previousFrame.functionName;
  });

  return symbolicateStack(deduplicatedStackFrames, shouldCache, fetchFunction);
};
