package io.sentry.react;

import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot;
import static io.sentry.vendor.Base64.NO_PADDING;
import static io.sentry.vendor.Base64.NO_WRAP;
import static java.util.concurrent.TimeUnit.SECONDS;

import android.app.Activity;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.net.Uri;
import android.util.SparseIntArray;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.FrameMetricsAggregator;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import com.facebook.hermes.instrumentation.HermesSamplingProfiler;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReadableMapKeySetIterator;
import com.facebook.react.bridge.ReadableType;
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableNativeArray;
import com.facebook.react.bridge.WritableNativeMap;
import io.sentry.ILogger;
import io.sentry.IScope;
import io.sentry.ISentryExecutorService;
import io.sentry.ISerializer;
import io.sentry.ScopesAdapter;
import io.sentry.Sentry;
import io.sentry.SentryAttributes;
import io.sentry.SentryDate;
import io.sentry.SentryDateProvider;
import io.sentry.SentryExecutorService;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.android.core.AndroidProfiler;
import io.sentry.android.core.BuildInfoProvider;
import io.sentry.android.core.InternalSentrySdk;
import io.sentry.android.core.SentryAndroidDateProvider;
import io.sentry.android.core.SentryAndroidOptions;
import io.sentry.android.core.SentryFramesDelayResult;
import io.sentry.android.core.SentryShakeDetector;
import io.sentry.android.core.ViewHierarchyEventProcessor;
import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader;
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
import io.sentry.android.core.performance.AppStartMetrics;
import io.sentry.profilemeasurements.ProfileMeasurement;
import io.sentry.profilemeasurements.ProfileMeasurementValue;
import io.sentry.protocol.SdkVersion;
import io.sentry.protocol.SentryId;
import io.sentry.protocol.User;
import io.sentry.protocol.ViewHierarchy;
import io.sentry.util.DebugMetaPropertiesApplier;
import io.sentry.util.FileUtils;
import io.sentry.util.JsonSerializationUtils;
import io.sentry.util.LoadClass;
import io.sentry.util.MapObjectReader;
import io.sentry.vendor.Base64;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;

public class RNSentryModuleImpl {

  public static final String NAME = "RNSentry";

  private static final RNSentryLogger rnLogger = new RNSentryLogger();
  private static final ILogger logger = rnLogger;
  private static final BuildInfoProvider buildInfo = new BuildInfoProvider(logger);
  private static final String modulesPath = "modules.json";
  private static final Charset UTF_8 = Charset.forName("UTF-8"); // NOPMD - Allow using UTF-8

  private final ReactApplicationContext reactApplicationContext;
  private final PackageInfo packageInfo;
  private FrameMetricsAggregator frameMetricsAggregator = null;
  @VisibleForTesting @Nullable SentryFrameMetricsCollector frameMetricsCollector = null;
  private @Nullable String frameMetricsListenerId = null;
  private boolean androidXAvailable;

  @VisibleForTesting static long lastStartTimestampMs = -1;

  // 700ms to constitute frozen frames.
  private static final int FROZEN_FRAME_THRESHOLD = 700;
  // 16ms (slower than 60fps) to constitute slow frames.
  private static final int SLOW_FRAME_THRESHOLD = 16;

  private static final int SCREENSHOT_TIMEOUT_SECONDS = 2;

  /**
   * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 to avoid possible
   * lockstep sampling. More on
   * https://stackoverflow.com/questions/45470758/what-is-lockstep-sampling
   */
  private int profilingTracesHz = 101;

  // Atomic because invalidate() (NativeModules/teardown thread) can null the reference
  // concurrently with stopProfiling() (JS thread). getAndSet gives us an atomic snapshot-and-clear.
  private final AtomicReference<AndroidProfiler> androidProfiler = new AtomicReference<>();

  // Atomic so that invalidate() can claim cleanup responsibility via compareAndSet/getAndSet.
  // startProfiling/stopProfiling run on the JS thread (@ReactMethod isBlockingSynchronousMethod);
  // invalidate runs on the NativeModules/teardown thread. Without atomicity, invalidate could
  // observe a stale false and skip disabling a running Hermes sampler.
  private final AtomicBoolean isProfiling = new AtomicBoolean(false);

  private boolean isProguardDebugMetaLoaded = false;
  private @Nullable String proguardUuid = null;
  private String cacheDirPath = null;
  private ISentryExecutorService executorService = null;

  private final @NotNull Runnable emitNewFrameEvent;

  private static final String ON_SHAKE_EVENT = "rn_sentry_on_shake";
  private @Nullable SentryShakeDetector shakeDetector;

  /** Max trace file size in bytes. */
  private long maxTraceFileSize = 5 * 1024 * 1024;

  private final @NotNull SentryDateProvider dateProvider;
  private final @NotNull LoadClass loadClass;

  public RNSentryModuleImpl(ReactApplicationContext reactApplicationContext) {
    packageInfo = getPackageInfo(reactApplicationContext);
    this.reactApplicationContext = reactApplicationContext;
    this.emitNewFrameEvent = createEmitNewFrameEvent();
    this.dateProvider = new SentryAndroidDateProvider();
    this.loadClass = new LoadClass();
  }

  private ReactApplicationContext getReactApplicationContext() {
    return this.reactApplicationContext;
  }

  private @Nullable Activity getCurrentActivity() {
    return this.reactApplicationContext.getCurrentActivity();
  }

  private @NotNull Runnable createEmitNewFrameEvent() {
    return () -> {
      final SentryDate endDate = dateProvider.now();
      RNSentryTimeToDisplay.putTimeToInitialDisplayForActiveSpan(endDate.nanoTimestamp() / 1e9);
    };
  }

  private void initFragmentInitialFrameTracking() {
    final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer =
        new RNSentryReactFragmentLifecycleTracer(buildInfo, emitNewFrameEvent, logger);

    final @Nullable FragmentActivity fragmentActivity = (FragmentActivity) getCurrentActivity();
    if (fragmentActivity != null) {
      final @Nullable FragmentManager supportFragmentManager =
          fragmentActivity.getSupportFragmentManager();
      if (supportFragmentManager != null) {
        supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true);
      }
    }
  }

  public void initNativeReactNavigationNewFrameTracking(Promise promise) {
    this.initFragmentInitialFrameTracking();
  }

  public void initNativeSdk(final ReadableMap rnOptions, Promise promise) {
    // Set the React context for the logger so it can forward logs to JS
    rnLogger.setReactContext(this.reactApplicationContext);

    try {
      RNSentryStart.startWithOptions(
          getApplicationContext(),
          rnOptions,
          getCurrentActivity(),
          options -> {
            // Use our custom logger that forwards to JS
            options.setLogger(rnLogger);
          },
          logger);
    } catch (Throwable e) { // NOPMD - mirror iOS reject-on-failure behavior
      logger.log(SentryLevel.ERROR, "Failed to initialize Sentry Android SDK", e);
      promise.reject("SentryReactNative", e.getMessage(), e);
      return;
    }

    // Toggle the TurboModule perf-logger sink based on the JS option. The
    // sink lazy-installs the native `NativeModulePerfLogger` on first enable;
    // we therefore want this to run only after the native SDK has started
    // successfully — otherwise we'd claim React Native's perf-logger slot
    // while no Sentry SDK is around to consume the data.
    //
    // Always reconcile to a concrete boolean (defaulting to `false`) so a
    // re-init that omits the key cannot leave a previous opt-in latched on:
    // the native controller is process-wide and not reset by closeNativeSdk.
    // The explicit `ReadableType.Boolean` check guards against JS passing a
    // non-boolean (number, string, null), which would crash `getBoolean`
    // with `UnexpectedNativeTypeException`.
    final boolean enableTurboModuleTracking =
        rnOptions.hasKey("enableTurboModuleTracking")
            && rnOptions.getType("enableTurboModuleTracking") == ReadableType.Boolean
            && rnOptions.getBoolean("enableTurboModuleTracking");
    // Defensive: the tracker only catches `UnsatisfiedLinkError` itself, but
    // `System.loadLibrary` can also raise `SecurityException` and the native
    // method could throw arbitrary `RuntimeException`. An uncaught exception
    // here would skip `promise.resolve(true)` and leave the JS-side
    // `initNativeSdk` promise pending forever. The SDK has already started
    // successfully by this point, so treat any tracking-toggle failure as
    // non-fatal and just log it.
    try {
      RNSentryTurboModulePerfTracker.setEnabled(enableTurboModuleTracking);
    } catch (Throwable t) { // NOPMD - tracking is best-effort, must not break init
      logger.log(
          SentryLevel.WARNING, "Failed to toggle TurboModule perf tracking: " + t.getMessage());
    }

    promise.resolve(true);
  }

  @TestOnly
  protected Context getApplicationContext() {
    final Context context = this.getReactApplicationContext().getApplicationContext();
    if (context == null) {
      logger.log(
          SentryLevel.ERROR, "ApplicationContext is null, using ReactApplicationContext fallback.");
      return this.getReactApplicationContext();
    }
    return context;
  }

  public void crash() {
    throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)");
  }

  public void addListener(String eventType) {
    // Is must be defined otherwise the generated interface from TS won't be
    // fulfilled
    logger.log(SentryLevel.ERROR, "addListener of NativeEventEmitter can't be used on Android!");
  }

  public void removeListeners(double id) {
    // removeListeners does not carry event-type information, so it cannot be used
    // to track shake listeners selectively. Shake detection is managed exclusively
    // via enableShakeDetection / disableShakeDetection.
  }

  private void startShakeDetection() {
    if (shakeDetector != null) {
      return;
    }

    try { // NOPMD - We don't want to crash in any case
      final ReactApplicationContext context = getReactApplicationContext();
      shakeDetector = new SentryShakeDetector(logger);
      shakeDetector.start(
          context,
          () -> {
            try { // NOPMD - We don't want to crash in any case
              final ReactApplicationContext ctx = getReactApplicationContext();
              if (ctx.hasActiveReactInstance()) {
                ctx.getJSModule(
                        com.facebook.react.modules.core.DeviceEventManagerModule
                            .RCTDeviceEventEmitter.class)
                    .emit(ON_SHAKE_EVENT, null);
              }
            } catch (Throwable e) { // NOPMD - We don't want to crash in any case
              logger.log(SentryLevel.WARNING, "Failed to emit shake event.", e);
            }
          });
    } catch (Throwable e) { // NOPMD - We don't want to crash in any case
      logger.log(SentryLevel.WARNING, "Failed to start shake detection.", e);
      shakeDetector = null;
    }
  }

  private void stopShakeDetection() {
    try { // NOPMD - We don't want to crash in any case
      if (shakeDetector != null) {
        shakeDetector.stop();
        shakeDetector = null;
      }
    } catch (Throwable e) { // NOPMD - We don't want to crash in any case
      logger.log(SentryLevel.WARNING, "Failed to stop shake detection.", e);
      shakeDetector = null;
    }
  }

  public void enableShakeDetection() {
    startShakeDetection();
  }

  public void disableShakeDetection() {
    stopShakeDetection();
  }

  public void pauseAppHangTracking() {
    // No-op: App hang tracking is iOS-only
  }

  public void resumeAppHangTracking() {
    // No-op: App hang tracking is iOS-only
  }

  public void fetchModules(Promise promise) {
    final AssetManager assets = this.getReactApplicationContext().getResources().getAssets();
    try (InputStream stream = new BufferedInputStream(assets.open(modulesPath))) {
      int size = stream.available();
      byte[] buffer = new byte[size];
      stream.read(buffer);
      stream.close();
      String modulesJson = new String(buffer, UTF_8);
      promise.resolve(modulesJson);
    } catch (FileNotFoundException e) {
      promise.resolve(null);
    } catch (Throwable e) { // NOPMD - We don't want to crash in any case
      logger.log(SentryLevel.WARNING, "Fetching JS Modules failed.");
      promise.resolve(null);
    }
  }

  public void fetchNativeRelease(Promise promise) {
    WritableMap release = Arguments.createMap();
    release.putString("id", packageInfo.packageName);
    release.putString("version", packageInfo.versionName);
    release.putString("build", String.valueOf(packageInfo.versionCode));
    promise.resolve(release);
  }

  public void fetchNativeAppStart(Promise promise) {
    fetchNativeAppStart(
        promise, AppStartMetrics.getInstance(), InternalSentrySdk.getAppStartMeasurement(), logger);
  }

  protected void fetchNativeAppStart(
      Promise promise,
      final AppStartMetrics metrics,
      final Map<String, Object> metricsDataBag,
      ILogger logger) {
    if (!metrics.isAppLaunchedInForeground()) {
      logger.log(SentryLevel.WARNING, "Invalid app start data: app not launched in foreground.");
      promise.resolve(null);
      return;
    }

    WritableMap mutableMeasurement =
        (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag);

    long currentStartTimestampMs = metrics.getAppStartTimeSpan().getStartTimestampMs();
    boolean hasFetched =
        lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs;
    mutableMeasurement.putBoolean("has_fetched", hasFetched);

    if (lastStartTimestampMs < 0) {
      logger.log(SentryLevel.DEBUG, "App Start data reported to the RN layer for the first time.");
    } else if (hasFetched) {
      logger.log(SentryLevel.DEBUG, "App Start data already fetched from native before.");
    } else {
      logger.log(SentryLevel.DEBUG, "App Start data updated, reporting to the RN layer again.");
    }

    // When activity is destroyed but the application process is kept alive
    // the next activity creation is considered warm start.
    // The app start metrics will be updated by the the Android SDK.
    // To let the RN JS layer know these are new start data we compare the start
    // timestamps.
    lastStartTimestampMs = currentStartTimestampMs;

    // Clears start metrics, making them ready for recording warm app start
    metrics.onAppStartSpansSent();

    promise.resolve(mutableMeasurement);
  }

  /** Returns frames metrics at the current point in time. */
  public void fetchNativeFrames(Promise promise) {
    if (!isFrameMetricsAggregatorAvailable()) {
      promise.resolve(null);
    } else {
      try {
        int totalFrames = 0;
        int slowFrames = 0;
        int frozenFrames = 0;

        final SparseIntArray[] framesRates = frameMetricsAggregator.getMetrics();

        if (framesRates != null) {
          final SparseIntArray totalIndexArray = framesRates[FrameMetricsAggregator.TOTAL_INDEX];
          if (totalIndexArray != null) {
            for (int i = 0; i < totalIndexArray.size(); i++) {
              int frameTime = totalIndexArray.keyAt(i);
              int numFrames = totalIndexArray.valueAt(i);
              totalFrames += numFrames;
              // hard coded values, its also in the official android docs and frame metrics
              // API
              if (frameTime > FROZEN_FRAME_THRESHOLD) {
                // frozen frames, threshold is 700ms
                frozenFrames += numFrames;
              } else if (frameTime > SLOW_FRAME_THRESHOLD) {
                // slow frames, above 16ms, 60 frames/second
                slowFrames += numFrames;
              }
            }
          }
        }

        WritableMap map = Arguments.createMap();
        map.putInt("totalFrames", totalFrames);
        map.putInt("slowFrames", slowFrames);
        map.putInt("frozenFrames", frozenFrames);

        promise.resolve(map);
      } catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
        logger.log(SentryLevel.WARNING, "Error fetching native frames.");
        promise.resolve(null);
      }
    }
  }

  public void fetchNativeFramesDelay(
      double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
    try {
      // Convert wall-clock seconds to System.nanoTime() based nanos
      long nowNanos = System.nanoTime();
      double nowSeconds = System.currentTimeMillis() / 1e3;

      double startOffsetSeconds = nowSeconds - startTimestampSeconds;
      double endOffsetSeconds = nowSeconds - endTimestampSeconds;

      if (startOffsetSeconds < 0
          || endOffsetSeconds < 0
          || (long) (startOffsetSeconds * 1e9) > nowNanos
          || (long) (endOffsetSeconds * 1e9) > nowNanos) {
        promise.resolve(null);
        return;
      }

      long startNanos = nowNanos - (long) (startOffsetSeconds * 1e9);
      long endNanos = nowNanos - (long) (endOffsetSeconds * 1e9);

      if (frameMetricsCollector == null) {
        promise.resolve(null);
        return;
      }

      SentryFramesDelayResult result = frameMetricsCollector.getFramesDelay(startNanos, endNanos);
      if (result != null && result.getDelaySeconds() >= 0) {
        promise.resolve(result.getDelaySeconds());
      } else {
        promise.resolve(null);
      }
    } catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
      logger.log(SentryLevel.WARNING, "Error fetching native frames delay.");
      promise.resolve(null);
    }
  }

  public void captureReplay(boolean isHardCrash, Promise promise) {
    Sentry.getCurrentScopes().getOptions().getReplayController().captureReplay(isHardCrash);
    promise.resolve(getCurrentReplayId());
  }

  public @Nullable String getCurrentReplayId() {
    final @Nullable IScope scope = InternalSentrySdk.getCurrentScope();
    if (scope == null) {
      return null;
    }

    final @NotNull SentryId id = scope.getReplayId();
    if (id == SentryId.EMPTY_ID) {
      return null;
    }
    return id.toString();
  }

  public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) {
    byte[] bytes = Base64.decode(rawBytes, Base64.DEFAULT);

    try {
      InternalSentrySdk.captureEnvelope(
          bytes, !options.hasKey("hardCrashed") || !options.getBoolean("hardCrashed"));
    } catch (Throwable e) { // NOPMD - We don't want to crash in any case
      logger.log(SentryLevel.ERROR, "Error while capturing envelope");
      promise.resolve(false);
    }
    promise.resolve(true);
  }

  public void captureScreenshot(Promise promise) {

    final Activity activity = getCurrentActivity();
    if (activity == null) {
      logger.log(SentryLevel.WARNING, "CurrentActivity is null, can't capture screenshot.");
      promise.resolve(null);
      return;
    }

    final byte[] raw = takeScreenshotOnUiThread(activity);

    if (raw == null || raw.length == 0) {
      logger.log(SentryLevel.WARNING, "Screenshot is null, screen was not captured.");
      promise.resolve(null);
      return;
    }

    final WritableNativeArray data = new WritableNativeArray();
    for (final byte b : raw) {
      data.pushInt(b);
    }
    final WritableMap screenshot = new WritableNativeMap();
    screenshot.putString("contentType", "image/png");
    screenshot.putArray("data", data);
    screenshot.putString("filename", "screenshot.png");

    final WritableArray screenshotsArray = new WritableNativeArray();
    screenshotsArray.pushMap(screenshot);
    promise.resolve(screenshotsArray);
  }

  private static byte[] takeScreenshotOnUiThread(Activity activity) {
    CountDownLatch doneSignal = new CountDownLatch(1);
    final byte[][] bytesWrapper = {{}}; // wrapper to be able to set the value in the runnable
    final Runnable runTakeScreenshot =
        () -> {
          bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo);
          doneSignal.countDown();
        };

    if (UiThreadUtil.isOnUiThread()) {
      runTakeScreenshot.run();
    } else {
      UiThreadUtil.runOnUiThread(runTakeScreenshot);
    }

    try {
      doneSignal.await(SCREENSHOT_TIMEOUT_SECONDS, SECONDS);
    } catch (InterruptedException e) {
      logger.log(SentryLevel.ERROR, "Screenshot process was interrupted.");
      return new byte[0];
    }

    return bytesWrapper[0];
  }

  public void fetchViewHierarchy(Promise promise) {
    final @Nullable Activity activity = getCurrentActivity();
    final @Nullable ViewHierarchy viewHierarchy =
        ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger);
    if (viewHierarchy == null) {
      logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy.");
      promise.resolve(null);
      return;
    }

    ISerializer serializer = ScopesAdapter.getInstance().getOptions().getSerializer();
    final @Nullable byte[] bytes =
        JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy);
    if (bytes == null) {
      logger.log(SentryLevel.ERROR, "Could not serialize ViewHierarchy.");
      promise.resolve(null);
      return;
    }
    if (bytes.length < 1) {
      logger.log(SentryLevel.ERROR, "Got empty bytes array after serializing ViewHierarchy.");
      promise.resolve(null);
      return;
    }

    final WritableNativeArray data = new WritableNativeArray();
    for (final byte b : bytes) {
      data.pushInt(b);
    }
    promise.resolve(data);
  }

  private static PackageInfo getPackageInfo(Context ctx) {
    try {
      return ctx.getPackageManager().getPackageInfo(ctx.getPackageName(), 0);
    } catch (PackageManager.NameNotFoundException e) {
      logger.log(SentryLevel.WARNING, "Error getting package info.");
      return null;
    }
  }

  public void setUser(final ReadableMap userKeys, final ReadableMap userDataKeys) {
    Sentry.configureScope(
        scope -> {
          if (userKeys == null && userDataKeys == null) {
            scope.setUser(null);
          } else {
            try {
              final MapObjectReader reader =
                  new MapObjectReader(
                      userKeys != null
                          ? RNSentryBreadcrumb.toDeepHashMap(userKeys)
                          : new HashMap<>());
              final User userInstance = new User.Deserializer().deserialize(reader, logger);

              if (userDataKeys != null) {
                Map<String, String> userDataMap = new HashMap<>();
                ReadableMapKeySetIterator it = userDataKeys.keySetIterator();
                while (it.hasNextKey()) {
                  String key = it.nextKey();
                  String value = userDataKeys.getString(key);

                  // other is ConcurrentHashMap and can't have null values
                  if (value != null) {
                    userDataMap.put(key, value);
                  }
                }

                userInstance.setData(userDataMap);
              }

              scope.setUser(userInstance);
            } catch (Exception e) {
              logger.log(SentryLevel.ERROR, "Failed to deserialize user from map.", e);
              scope.setUser(null);
            }
          }
        });
  }

  public void addBreadcrumb(final ReadableMap breadcrumb) {
    Sentry.configureScope(
        scope -> {
          scope.addBreadcrumb(RNSentryBreadcrumb.fromMap(breadcrumb, logger));

          final @Nullable String screen = RNSentryBreadcrumb.getCurrentScreenFrom(breadcrumb);
          if (screen != null) {
            scope.setScreen(screen);
          }
        });
  }

  public void clearBreadcrumbs() {
    Sentry.configureScope(
        scope -> {
          scope.clearBreadcrumbs();
        });
  }

  public void popTimeToDisplayFor(String screenId, Promise promise) {
    if (screenId != null) {
      promise.resolve(RNSentryTimeToDisplay.popTimeToDisplayFor(screenId));
    } else {
      promise.resolve(null);
    }
  }

  public boolean setActiveSpanId(@Nullable String spanId) {
    RNSentryTimeToDisplay.setActiveSpanId(spanId);
    return true; // The return ensure RN executes the code synchronously
  }

  public void setExtra(String key, String extra) {
    if (key == null || extra == null) {
      logger.log(
          SentryLevel.ERROR,
          "RNSentry.setExtra called with null key or value, can't change extra.");
      return;
    }

    Sentry.configureScope(
        scope -> {
          scope.setExtra(key, extra);
        });
  }

  public void setContext(final String key, final ReadableMap context) {
    if (key == null) {
      logger.log(
          SentryLevel.ERROR, "RNSentry.setContext called with null key, can't change context.");
      return;
    }

    Sentry.configureScope(
        scope -> {
          if (context == null) {
            scope.removeContexts(key);
            return;
          }

          final Map<String, Object> contextHashMap = context.toHashMap();
          scope.setContexts(key, contextHashMap);
        });
  }

  public void setTag(String key, String value) {
    Sentry.configureScope(
        scope -> {
          scope.setTag(key, value);
        });
  }

  public void setAttribute(String key, String value) {
    Sentry.configureScope(
        scope -> {
          scope.setAttribute(key, value);
        });
  }

  public void setAttributes(ReadableMap attributes) {
    Sentry.configureScope(
        scope -> {
          final Map<String, Object> attributesHashMap = attributes.toHashMap();
          scope.setAttributes(SentryAttributes.fromMap(attributesHashMap));
        });
  }

  public void removeAttribute(String key) {
    Sentry.configureScope(
        scope -> {
          scope.removeAttribute(key);
        });
  }

  public void closeNativeSdk(Promise promise) {
    Sentry.close();

    disableNativeFramesTracking();

    promise.resolve(true);
  }

  public void enableNativeFramesTracking() {
    androidXAvailable = checkAndroidXAvailability();

    if (androidXAvailable) {
      frameMetricsAggregator = new FrameMetricsAggregator();
      final Activity currentActivity = getCurrentActivity();

      if (frameMetricsAggregator != null && currentActivity != null) {
        try {
          frameMetricsAggregator.add(currentActivity);

          logger.log(SentryLevel.INFO, "FrameMetricsAggregator installed.");
        } catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
          // throws ConcurrentModification when calling addOnFrameMetricsAvailableListener
          // this is a best effort since we can't reproduce it
          logger.log(SentryLevel.ERROR, "Error adding Activity to frameMetricsAggregator.");
        }
      } else {
        logger.log(SentryLevel.INFO, "currentActivity isn't available.");
      }
    } else {
      logger.log(SentryLevel.WARNING, "androidx.core' isn't available as a dependency.");
    }

    try {
      final SentryOptions options = Sentry.getCurrentScopes().getOptions();
      if (options instanceof SentryAndroidOptions) {
        final SentryFrameMetricsCollector collector =
            ((SentryAndroidOptions) options).getFrameMetricsCollector();
        if (collector != null) {
          // Register a no-op listener to ensure frame metrics collection is active.
          // This is needed so that getFramesDelay() has data to query.
          stopFrameMetricsCollection();
          String listenerId =
              collector.startCollection(
                  (startNanos,
                      endNanos,
                      durationNanos,
                      delayNanos,
                      isSlow,
                      isFrozen,
                      refreshRate) -> {});
          if (listenerId != null) {
            frameMetricsCollector = collector;
            frameMetricsListenerId = listenerId;
            logger.log(SentryLevel.INFO, "SentryFrameMetricsCollector listener installed.");
          }
        }
      }
    } catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
      logger.log(SentryLevel.WARNING, "Error starting frame metrics collection.");
    }
  }

  public void disableNativeFramesTracking() {
    if (isFrameMetricsAggregatorAvailable()) {
      frameMetricsAggregator.stop();
      frameMetricsAggregator = null;
    }
    stopFrameMetricsCollection();
  }

  private void stopFrameMetricsCollection() {
    if (frameMetricsCollector != null && frameMetricsListenerId != null) {
      frameMetricsCollector.stopCollection(frameMetricsListenerId);
    }
    frameMetricsCollector = null;
    frameMetricsListenerId = null;
  }

  public void getNewScreenTimeToDisplay(Promise promise) {
    RNSentryTimeToDisplay.getTimeToDisplay(promise, dateProvider);
  }

  private String getProfilingTracesDirPath() {
    if (cacheDirPath == null) {
      cacheDirPath =
          new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath();
    }
    File profilingTraceDir = new File(cacheDirPath, "profiling_trace");
    profilingTraceDir.mkdirs();
    return profilingTraceDir.getAbsolutePath();
  }

  private void initializeAndroidProfiler() {
    if (executorService == null) {
      executorService = new SentryExecutorService();
    }
    final String tracesFilesDirPath = getProfilingTracesDirPath();

    SentryFrameMetricsCollector collector = null;
    try {
      final SentryOptions options = Sentry.getCurrentScopes().getOptions();
      if (options instanceof SentryAndroidOptions) {
        collector = ((SentryAndroidOptions) options).getFrameMetricsCollector();
      }
    } catch (Throwable ignored) { // NOPMD - Best-effort
    }
    if (collector == null) {
      collector = new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo);
    }

    androidProfiler.set(
        new AndroidProfiler(
            tracesFilesDirPath,
            (int) SECONDS.toMicros(1) / profilingTracesHz,
            collector,
            () -> executorService,
            logger));
  }

  public WritableMap startProfiling(boolean platformProfilers) {
    final WritableMap result = new WritableNativeMap();
    if (androidProfiler.get() == null && platformProfilers) {
      initializeAndroidProfiler();
    }

    try {
      // Defensive reset: the Hermes sampling profiler is global with no state
      // introspection, so flush any leaked registration from a prior run before
      // enabling. See https://github.com/facebook/hermes/issues/1853.
      HermesSamplingProfiler.disable();
      HermesSamplingProfiler.enable();
      // Mark profiling active immediately after enable() succeeds so a concurrent
      // invalidate() observes the running state even if androidProfiler.start() below throws.
      isProfiling.set(true);
      final AndroidProfiler profiler = androidProfiler.get();
      if (profiler != null) {
        profiler.start();
      }

      result.putBoolean("started", true);
    } catch (Throwable e) { // NOPMD - We don't want to crash in any case
      // Unwind Hermes if we enabled it but failed before returning. getAndSet(false) makes
      // this idempotent with a concurrent invalidate()/stopProfiling().
      if (isProfiling.getAndSet(false)) {
        try {
          HermesSamplingProfiler.disable();
        } catch (Throwable ignored) { // NOPMD - Best-effort unwind.
        }
      }
      result.putBoolean("started", false);
      result.putString("error", e.toString());
    }
    return result;
  }

  public WritableMap stopProfiling() {
    final boolean isDebug = ScopesAdapter.getInstance().getOptions().isDebug();
    final WritableMap result = new WritableNativeMap();
    // Claim cleanup ownership atomically. If invalidate() got here first, it already
    // disabled Hermes and collected the Android profiler; there's nothing to return.
    if (!isProfiling.compareAndSet(true, false)) {
      result.putString("error", "Profiling not active");
      return result;
    }
    // Take sole ownership of the AndroidProfiler reference so a concurrent invalidate()
    // cannot also call endAndCollect() on the same instance.
    final AndroidProfiler profiler = androidProfiler.getAndSet(null);
    File output = null;
    try {
      AndroidProfiler.ProfileEndData end = null;
      if (profiler != null) {
        end = profiler.endAndCollect(false, null);
      }
      HermesSamplingProfiler.disable();

      output =
          File.createTempFile(
              "sampling-profiler-trace", ".cpuprofile", reactApplicationContext.getCacheDir());
      if (isDebug) {
        logger.log(SentryLevel.INFO, "Profile saved to: " + output.getAbsolutePath());
      }

      HermesSamplingProfiler.dumpSampledTraceToFile(output.getPath());
      result.putString("profile", readStringFromFile(output));

      if (end != null) {
        WritableMap androidProfile = new WritableNativeMap();
        byte[] androidProfileBytes =
            FileUtils.readBytesFromFile(end.traceFile.getPath(), maxTraceFileSize);
        String base64AndroidProfile =
            Base64.encodeToString(androidProfileBytes, NO_WRAP | NO_PADDING);

        androidProfile.putString("sampled_profile", base64AndroidProfile);
        androidProfile.putInt("android_api_level", buildInfo.getSdkInfoVersion());
        androidProfile.putString("build_id", getProguardUuid());

        if (end.measurementsMap != null && !end.measurementsMap.isEmpty()) {
          WritableMap measurements = new WritableNativeMap();
          for (Map.Entry<String, ProfileMeasurement> entry : end.measurementsMap.entrySet()) {
            WritableMap measurement = new WritableNativeMap();
            measurement.putString("unit", entry.getValue().getUnit());
            WritableArray values = new WritableNativeArray();
            if (entry.getValue().getValues() != null) {
              for (ProfileMeasurementValue pmv : entry.getValue().getValues()) {
                WritableMap value = new WritableNativeMap();
                value.putString("elapsed_since_start_ns", pmv.getRelativeStartNs());
                value.putDouble("value", pmv.getValue());
                values.pushMap(value);
              }
            }
            measurement.putArray("values", values);
            measurements.putMap(entry.getKey(), measurement);
          }
          androidProfile.putMap("measurements", measurements);
        }

        result.putMap("androidProfile", androidProfile);
      }
    } catch (Throwable e) { // NOPMD - We don't want to crash in any case
      result.putString("error", e.toString());
    } finally {
      if (output != null) {
        try {
          final boolean wasProfileSuccessfullyDeleted = output.delete();
          if (!wasProfileSuccessfullyDeleted) {
            logger.log(SentryLevel.WARNING, "Profile not deleted from:" + output.getAbsolutePath());
          }
        } catch (Throwable e) { // NOPMD - We don't want to crash in any case
          logger.log(SentryLevel.WARNING, "Profile not deleted from:" + output.getAbsolutePath());
        }
      }
    }
    return result;
  }

  /**
   * Cleanup hook invoked from the module wrappers when the React instance is destroyed (e.g. Metro
   * reload, orderly Activity teardown). Calls HermesSamplingProfiler.disable(), which synchronously
   * joins the sampler's timer thread, so no pthread_kill can fire after the JS thread is torn down.
   *
   * <p>Correctness depends on RN invoking module invalidate() before joining the JS message queue
   * thread. That is the documented contract for both old-arch CatalystInstance shutdown and
   * new-arch TurboModule teardown.
   *
   * <p>See https://github.com/getsentry/sentry-react-native/issues/5441 and
   * https://github.com/facebook/hermes/issues/1853.
   */
  public void invalidate() {
    // Atomic gate: only one caller (invalidate vs stopProfiling vs a re-entrant invalidate)
    // wins the right to clean up; the rest no-op.
    if (!isProfiling.getAndSet(false)) {
      return;
    }
    // getAndSet(null) atomically snatches the reference so a concurrent stopProfiling()
    // sees null and skips its own endAndCollect() on the same instance.
    final AndroidProfiler profiler = androidProfiler.getAndSet(null);

    // Crash-critical: disable the Hermes sampler first. disable() synchronously joins the
    // timer thread, so once it returns no pthread_kill can fire after the JS thread is
    // joined. Isolated in its own try/catch so a failure in the Android profiler cleanup
    // below cannot skip this call.
    try {
      HermesSamplingProfiler.disable();
      logger.log(SentryLevel.INFO, "Stopped Hermes sampling profiler on React instance destroy.");
    } catch (Throwable e) { // NOPMD - Guards the JNI boundary; does not catch native SIGABRT.
      logger.log(SentryLevel.WARNING, "Failed to stop Hermes sampling profiler on teardown: " + e);
    }

    // Android platform profiler is independent of Hermes; a failure here must not leak
    // back into the Hermes shutdown above. The profile is being discarded; delete the
    // trace file to avoid leaking it in cacheDir.
    if (profiler != null) {
      try {
        final AndroidProfiler.ProfileEndData end = profiler.endAndCollect(false, null);
        if (end != null && end.traceFile != null) {
          try {
            end.traceFile.delete();
          } catch (Throwable ignored) { // NOPMD - File cleanup is best-effort.
          }
        }
      } catch (Throwable e) { // NOPMD - Android profiler cleanup is best-effort on teardown.
        logger.log(SentryLevel.WARNING, "AndroidProfiler cleanup failed during invalidate: " + e);
      }
    }
  }

  private @Nullable String getProguardUuid() {
    if (isProguardDebugMetaLoaded) {
      return proguardUuid;
    }
    isProguardDebugMetaLoaded = true;
    final @Nullable List<Properties> debugMetaList =
        new AssetsDebugMetaLoader(this.getReactApplicationContext(), logger).loadDebugMeta();
    if (debugMetaList == null) {
      return null;
    }

    for (Properties debugMeta : debugMetaList) {
      proguardUuid = DebugMetaPropertiesApplier.getProguardUuid(debugMeta);
      if (proguardUuid != null) {
        logger.log(SentryLevel.INFO, "Proguard uuid found: " + proguardUuid);
        return proguardUuid;
      }
    }

    logger.log(SentryLevel.WARNING, "No proguard uuid found in debug meta properties file!");
    return null;
  }

  private String readStringFromFile(File path) throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader(path)); ) {

      final StringBuilder text = new StringBuilder();
      String line;
      while ((line = br.readLine()) != null) {
        text.append(line);
        text.append('\n');
      }
      return text.toString();
    }
  }

  public void fetchNativeLogAttributes(Promise promise) {
    final @NotNull SentryOptions options = ScopesAdapter.getInstance().getOptions();
    final @Nullable Context context = this.getReactApplicationContext().getApplicationContext();
    final @Nullable IScope currentScope = InternalSentrySdk.getCurrentScope();
    fetchNativeLogContexts(promise, options, context, currentScope);
  }

  public void fetchNativeDeviceContexts(Promise promise) {
    final @NotNull SentryOptions options = ScopesAdapter.getInstance().getOptions();
    final @Nullable Context context = this.getReactApplicationContext().getApplicationContext();
    final @Nullable IScope currentScope = InternalSentrySdk.getCurrentScope();
    fetchNativeDeviceContexts(promise, options, context, currentScope);
  }

  protected void fetchNativeDeviceContexts(
      Promise promise,
      final @NotNull SentryOptions options,
      final @Nullable Context context,
      final @Nullable IScope currentScope) {
    if (!(options instanceof SentryAndroidOptions)) {
      promise.resolve(null);
      return;
    }
    if (context == null) {
      promise.resolve(null);
      return;
    }

    final @NotNull Map<String, Object> serialized =
        InternalSentrySdk.serializeScope(context, (SentryAndroidOptions) options, currentScope);

    final @Nullable Object serializedBreadcrumbs = serialized.get("breadcrumbs");
    if (serializedBreadcrumbs instanceof List) {
      final List<?> breadcrumbs = (List<?>) serializedBreadcrumbs;
      List<Map<?, ?>> filtered = new ArrayList<>();
      for (Object o : breadcrumbs) {
        if (o instanceof Map) {
          final Map<?, ?> breadcrumb = (Map<?, ?>) o;
          if (!"react-native".equals(breadcrumb.get("origin"))) {
            filtered.add(breadcrumb);
          }
        }
      }
      serialized.put("breadcrumbs", filtered);
    }

    final @Nullable Object deviceContext = RNSentryMapConverter.convertToWritable(serialized);
    promise.resolve(deviceContext);
  }

  // Basically fetchNativeDeviceContexts but filtered to only get contexts info.
  protected void fetchNativeLogContexts(
      Promise promise,
      final @NotNull SentryOptions options,
      final @Nullable Context osContext,
      final @Nullable IScope currentScope) {
    if (!(options instanceof SentryAndroidOptions) || osContext == null) {
      promise.resolve(null);
      return;
    }

    Object contextsObj =
        InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope)
            .get("contexts");

    if (!(contextsObj instanceof Map)) {
      promise.resolve(null);
      return;
    }

    @SuppressWarnings("unchecked")
    Map<String, Object> contextsMap = (Map<String, Object>) contextsObj;

    Map<String, Object> contextItems = new HashMap<>();
    if (contextsMap.containsKey("os")) {
      contextItems.put("os", contextsMap.get("os"));
    }

    if (contextsMap.containsKey("device")) {
      contextItems.put("device", contextsMap.get("device"));
    }

    contextItems.put("release", options.getRelease());

    Map<String, Object> logContext = new HashMap<>();
    logContext.put("contexts", contextItems);
    Object filteredContext = RNSentryMapConverter.convertToWritable(logContext);

    promise.resolve(filteredContext);
  }

  public void fetchNativeSdkInfo(Promise promise) {
    final @Nullable SdkVersion sdkVersion =
        ScopesAdapter.getInstance().getOptions().getSdkVersion();
    if (sdkVersion == null) {
      promise.resolve(null);
    } else {
      final WritableMap sdkInfo = new WritableNativeMap();
      sdkInfo.putString("name", sdkVersion.getName());
      sdkInfo.putString("version", sdkVersion.getVersion());
      promise.resolve(sdkInfo);
    }
  }

  public String fetchNativePackageName() {
    return packageInfo.packageName;
  }

  public void getDataFromUri(String uri, Promise promise) {
    final Uri parsedUri;
    try {
      parsedUri = Uri.parse(uri);
    } catch (Exception e) {
      String msg = "Invalid uri: " + uri;
      logger.log(SentryLevel.ERROR, msg);
      promise.reject(new Exception(msg));
      return;
    }

    if (!isAllowedUri(parsedUri, getReactApplicationContext())) {
      String msg = "Unsupported uri scheme or location: " + uri;
      logger.log(SentryLevel.ERROR, msg);
      promise.reject(new Exception(msg));
      return;
    }

    try {
      try (InputStream is =
          getReactApplicationContext().getContentResolver().openInputStream(parsedUri)) {
        if (is == null) {
          String msg = "File not found for uri: " + uri;
          logger.log(SentryLevel.ERROR, msg);
          promise.reject(new Exception(msg));
          return;
        }

        ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();
        int bufferSize = 1024;
        byte[] buffer = new byte[bufferSize];
        int len;
        while ((len = is.read(buffer)) != -1) {
          byteBuffer.write(buffer, 0, len);
        }
        byte[] byteArray = byteBuffer.toByteArray();
        WritableArray jsArray = Arguments.createArray();
        for (byte b : byteArray) {
          jsArray.pushInt(b & 0xFF);
        }
        promise.resolve(jsArray);
      }
    } catch (IOException e) {
      String msg = "Error reading uri: " + uri + ": " + e.getMessage();
      logger.log(SentryLevel.ERROR, msg);
      promise.reject(new Exception(msg));
    }
  }

  @VisibleForTesting
  static boolean isAllowedUri(@NotNull Uri uri, @Nullable Context ctx) {
    final String scheme = uri.getScheme();
    if (scheme == null) {
      return false;
    }
    final String lowerScheme = scheme.toLowerCase(Locale.ROOT);
    if ("content".equals(lowerScheme)) {
      return isAllowedContentAuthority(uri.getAuthority(), ctx);
    }
    return "file".equals(lowerScheme) && isPathUnderAppDirs(uri.getPath(), ctx);
  }

  // Allowlist is intentionally narrow:
  // - `media` is what the in-SDK image picker flow returns.
  // - the app's own FileProvider is needed when the host app exposes attachments via it.
  // SAF document authorities
  // (com.android.{externalstorage,providers.media,providers.downloads}.documents)
  // are deliberately excluded: authority-only matching would let any caller read any document
  // the app has been granted persistent SAF permission for, without verifying the URI was granted
  // in the current attach flow.
  @VisibleForTesting
  static boolean isAllowedContentAuthority(@Nullable String authority, @Nullable Context ctx) {
    if (authority == null) {
      return false;
    }
    final String lower = authority.toLowerCase(Locale.ROOT);
    if ("media".equals(lower)) {
      return true;
    }
    if (ctx != null) {
      final String pkg = ctx.getPackageName();
      if (pkg != null && lower.equals(pkg.toLowerCase(Locale.ROOT) + ".fileprovider")) {
        return true;
      }
    }
    return false;
  }

  @VisibleForTesting
  static boolean isPathUnderAppDirs(@Nullable String rawPath, @Nullable Context ctx) {
    if (rawPath == null || rawPath.isEmpty()) {
      return false;
    }
    if (ctx == null) {
      return false;
    }
    try {
      final String target = new File(rawPath).getCanonicalPath();
      final File[] roots =
          new File[] {
            ctx.getFilesDir(),
            ctx.getCacheDir(),
            ctx.getExternalFilesDir(null),
            ctx.getExternalCacheDir()
          };
      for (File root : roots) {
        if (root == null) {
          continue;
        }
        final String rootPath = root.getCanonicalPath();
        if (target.equals(rootPath) || target.startsWith(rootPath + File.separator)) {
          return true;
        }
      }
    } catch (IOException e) {
      return false;
    }
    return false;
  }

  public void encodeToBase64(ReadableArray array, Promise promise) {
    byte[] bytes = new byte[array.size()];
    for (int i = 0; i < array.size(); i++) {
      bytes[i] = (byte) array.getInt(i);
    }
    String base64String = android.util.Base64.encodeToString(bytes, android.util.Base64.DEFAULT);
    promise.resolve(base64String);
  }

  public void crashedLastRun(Promise promise) {
    promise.resolve(Sentry.isCrashedLastRun());
  }

  private boolean checkAndroidXAvailability() {
    try {
      Class.forName("androidx.core.app.FrameMetricsAggregator");
      return true;
    } catch (ClassNotFoundException ignored) {
      // androidx.core isn't available.
      return false;
    }
  }

  private boolean isFrameMetricsAggregatorAvailable() {
    return androidXAvailable && frameMetricsAggregator != null;
  }

  public static @Nullable String getURLFromDSN(@Nullable String dsn) {
    if (dsn == null) {
      return null;
    }
    URI uri = null;
    try {
      uri = new URI(dsn);
    } catch (URISyntaxException e) {
      return null;
    }
    return uri.getScheme() + "://" + uri.getHost();
  }

  @TestOnly
  protected void trySetIgnoreErrors(SentryAndroidOptions options, ReadableMap rnOptions) {
    ReadableArray regErrors = null;
    ReadableArray strErrors = null;
    if (rnOptions.hasKey("ignoreErrorsRegex")) {
      regErrors = rnOptions.getArray("ignoreErrorsRegex");
    }
    if (rnOptions.hasKey("ignoreErrorsStr")) {
      strErrors = rnOptions.getArray("ignoreErrorsStr");
    }
    if (regErrors == null && strErrors == null) {
      return;
    }

    int regSize = regErrors != null ? regErrors.size() : 0;
    int strSize = strErrors != null ? strErrors.size() : 0;
    List<String> list = new ArrayList<>(regSize + strSize);
    if (regErrors != null) {
      for (int i = 0; i < regErrors.size(); i++) {
        list.add(regErrors.getString(i));
      }
    }
    if (strErrors != null) {
      // Use the same behaviour of JavaScript instead of Android when dealing with
      // strings.
      for (int i = 0; i < strErrors.size(); i++) {
        String pattern = ".*" + Pattern.quote(strErrors.getString(i)) + ".*";
        list.add(pattern);
      }
    }
    options.setIgnoredErrors(list);
  }
}
